multi-image comics

This commit is contained in:
mi
2025-11-15 18:52:14 +10:00
parent f71720c156
commit 91b6d4efeb
7 changed files with 356 additions and 66 deletions

View File

@@ -43,9 +43,9 @@ Run this after adding/updating comics to regenerate `static/sitemap.xml` for sea
### Data Layer: comics_data.py ### Data Layer: comics_data.py
Comics are stored as a Python list called `COMICS`. Each comic is a dictionary with: Comics are stored as a Python list called `COMICS`. Each comic is a dictionary with:
- `number` (required): Sequential comic number - `number` (required): Sequential comic number
- `filename` (required): Image filename in `static/images/comics/` - `filename` (required): Image filename in `static/images/comics/` OR list of filenames for multi-image comics (webtoon style)
- `date` (required): Publication date in YYYY-MM-DD format - `date` (required): Publication date in YYYY-MM-DD format
- `alt_text` (required): Accessibility text - `alt_text` (required): Accessibility text OR list of alt texts (one per image for multi-image comics)
- `title` (optional): Comic title (defaults to "#X" if absent) - `title` (optional): Comic title (defaults to "#X" if absent)
- `author_note` (optional): Plain text note - `author_note` (optional): Plain text note
- `author_note_md` (optional): Markdown file for author note (just filename like "2025-01-01.md" looks in `content/author_notes/`, or path like "special/note.md" relative to `content/`). Takes precedence over `author_note`. - `author_note_md` (optional): Markdown file for author note (just filename like "2025-01-01.md" looks in `content/author_notes/`, or path like "special/note.md" relative to `content/`). Takes precedence over `author_note`.
@@ -53,6 +53,16 @@ Comics are stored as a Python list called `COMICS`. Each comic is a dictionary w
- `plain` (optional): Override global PLAIN_DEFAULT setting (hides header/border) - `plain` (optional): Override global PLAIN_DEFAULT setting (hides header/border)
- `section` (optional): Section/chapter title (e.g., "Chapter 1: Origins"). Add to first comic of a new section. - `section` (optional): Section/chapter title (e.g., "Chapter 1: Origins"). Add to first comic of a new section.
**Multi-image comics (webtoon style):**
- Set `filename` to a list of image filenames: `['page1.png', 'page2.png', 'page3.png']`
- Set `alt_text` to either:
- A single string (applies to all images): `'A three-part vertical story'`
- A list matching each image: `['Description 1', 'Description 2', 'Description 3']`
- If `alt_text` is a list but doesn't match `filename` length, a warning is logged
- Images display vertically with seamless stacking (no gaps)
- First image loads immediately; subsequent images lazy-load as user scrolls
- No click-through navigation on multi-image comics (use navigation buttons instead)
Global configuration in `comics_data.py`: Global configuration in `comics_data.py`:
- `COMIC_NAME`: Your comic/website name - `COMIC_NAME`: Your comic/website name
- `COPYRIGHT_NAME`: Name to display in copyright notice (defaults to COMIC_NAME if not set) - `COPYRIGHT_NAME`: Name to display in copyright notice (defaults to COMIC_NAME if not set)

114
README.md
View File

@@ -158,6 +158,7 @@ Don't have a server? No problem! Here are beginner-friendly options to get your
## Features ## Features
- Comic viewer with navigation (First, Previous, Next, Latest) - Comic viewer with navigation (First, Previous, Next, Latest)
- **Multi-image comics** with vertical scrolling (webtoon style) and lazy loading
- Client-side navigation using JSON API (no page reloads) - Client-side navigation using JSON API (no page reloads)
- Keyboard navigation support (arrow keys, Home/End) - Keyboard navigation support (arrow keys, Home/End)
- Archive page with thumbnail grid - Archive page with thumbnail grid
@@ -732,10 +733,10 @@ Comics are stored in the `COMICS` list in `comics_data.py`. Each comic entry:
```python ```python
{ {
'number': 1, # Comic number (required, sequential) 'number': 1, # Comic number (required, sequential)
'filename': 'comic-001.png', # Image filename (required) 'filename': 'comic-001.png', # Image filename (required) OR list for multi-image
'mobile_filename': 'comic-001-mobile.png', # Optional mobile version 'mobile_filename': 'comic-001-mobile.png', # Optional mobile version (single-image only)
'date': '2025-01-01', # Publication date (required) 'date': '2025-01-01', # Publication date (required)
'alt_text': 'Alt text for comic', # Accessibility text (required) 'alt_text': 'Alt text for comic', # Accessibility text (required) OR list for multi-image
'title': 'Comic Title', # Title (optional, shows #X if absent) 'title': 'Comic Title', # Title (optional, shows #X if absent)
'author_note': 'Optional note', # Author note (optional, plain text) 'author_note': 'Optional note', # Author note (optional, plain text)
'author_note_md': '2025-01-01.md', # Optional markdown file for author note 'author_note_md': '2025-01-01.md', # Optional markdown file for author note
@@ -744,6 +745,17 @@ Comics are stored in the `COMICS` list in `comics_data.py`. Each comic entry:
} }
``` ```
**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
}
```
### Adding a New Comic ### Adding a New Comic
**Option 1: Use the script (recommended)** **Option 1: Use the script (recommended)**
@@ -800,6 +812,102 @@ The `/about` route renders `content/about.md` as HTML. Edit this file to customi
**Note:** Client-side navigation displays author notes as plain text. Markdown author notes only render properly on initial page load (server-side rendering). For full markdown rendering, users need to refresh the page or navigate directly to the comic URL. **Note:** Client-side navigation displays author notes as plain text. Markdown author notes only render properly on initial page load (server-side rendering). For full markdown rendering, users need to refresh the page or navigate directly to the comic URL.
### Multi-Image Comics (Webtoon Style)
Sunday Comics supports vertical scrolling comics with multiple images stacked seamlessly, perfect for webtoon-style storytelling.
**How it works:**
- Set `filename` to a list of image filenames instead of a single string
- Images display vertically with no gaps between them
- First image loads immediately, subsequent images lazy-load as readers scroll
- 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',
}
```
**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',
}
```
**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.
**Features:**
- ✅ **Seamless vertical layout** - Images stack with no visible gaps
- ✅ **Lazy loading** - Only loads images as they scroll into view (performance optimization)
- ✅ **Responsive** - Works on desktop and mobile devices
- ✅ **Accessible** - Supports individual alt text for each image panel
- ✅ **Backward compatible** - Single-image comics continue to work as before
**Best Practices:**
1. **Image consistency** - Use the same width for all images in a multi-image comic for best results
2. **Alt text per panel** - Provide individual alt text for each image to describe what's happening in that section
3. **File naming** - Use descriptive, sequential names like `comic-004-panel-1.png`, `comic-004-panel-2.png`
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
}
```
**Technical Details:**
- Images appear in the order listed in the `filename` array
- If `alt_text` is a single string, it applies to all images
- If `alt_text` is a list, it must match the length of `filename` (or it will pad with empty strings)
- The first image in the array is used as the thumbnail in the archive page
- Lazy loading uses Intersection Observer API with 50px margin for smooth loading
**When to use multi-image:**
- Long-form vertical scrolling stories (webtoons, manhwa style)
- Comics with natural panel breaks across multiple images
- Stories that benefit from vertical pacing and reveals
**When to stick with single images:**
- Traditional comic strip or page layouts
- Self-contained single-panel comics
- When you want click-through navigation on the comic image
## Production Deployment ## Production Deployment
For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods: For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods:

46
app.py
View File

@@ -3,6 +3,7 @@
# Licensed under the MIT License - see LICENSE file for details # Licensed under the MIT License - see LICENSE file for details
import os import os
import logging
from datetime import datetime from datetime import datetime
from flask import Flask, render_template, abort, jsonify, request from flask import Flask, render_template, abort, jsonify, request
from comics_data import ( from comics_data import (
@@ -13,6 +14,10 @@ from comics_data import (
) )
import markdown import markdown
# Configure logging
logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger(__name__)
app = Flask(__name__) app = Flask(__name__)
# Configuration # Configuration
@@ -117,6 +122,47 @@ def enrich_comic(comic):
enriched['plain'] = is_plain(comic) enriched['plain'] = is_plain(comic)
enriched['formatted_date'] = format_comic_date(comic['date']) enriched['formatted_date'] = format_comic_date(comic['date'])
# Normalize filename to list for multi-image support
if isinstance(comic.get('filename'), list):
enriched['filenames'] = comic['filename']
enriched['is_multi_image'] = True
else:
enriched['filenames'] = [comic['filename']] if 'filename' in comic else []
enriched['is_multi_image'] = False
# Normalize alt_text to list matching filenames
if isinstance(comic.get('alt_text'), list):
enriched['alt_texts'] = comic['alt_text']
# Warn if alt_text list doesn't match filenames length
if len(enriched['alt_texts']) != len(enriched['filenames']):
logger.warning(
f"Comic #{comic['number']}: alt_text list length ({len(enriched['alt_texts'])}) "
f"doesn't match filenames length ({len(enriched['filenames'])}). "
f"Tip: Use a single string for alt_text to apply the same text to all images, "
f"or provide a list matching the number of images."
)
else:
# If single alt_text string, use it for all images (this is intentional and valid)
alt_text = comic.get('alt_text', '')
enriched['alt_texts'] = [alt_text] * len(enriched['filenames'])
# Ensure alt_texts list matches filenames length (pad with empty strings if too short)
while len(enriched['alt_texts']) < len(enriched['filenames']):
enriched['alt_texts'].append('')
# Trim alt_texts if too long (extra ones won't be used anyway)
if len(enriched['alt_texts']) > len(enriched['filenames']):
enriched['alt_texts'] = enriched['alt_texts'][:len(enriched['filenames'])]
# Keep original filename and alt_text for backward compatibility (first image)
if enriched['filenames']:
enriched['filename'] = enriched['filenames'][0]
# Ensure alt_text is always a string (use first one if it's a list)
if enriched['alt_texts']:
enriched['alt_text'] = enriched['alt_texts'][0]
# Check for explicitly specified markdown author note file # Check for explicitly specified markdown author note file
if 'author_note_md' in comic and comic['author_note_md']: if 'author_note_md' in comic and comic['author_note_md']:
markdown_note = get_author_note_from_file(comic['author_note_md']) markdown_note = get_author_note_from_file(comic['author_note_md'])

View File

@@ -431,6 +431,30 @@ main {
margin: 0 auto; margin: 0 auto;
} }
/* Multi-image comics (webtoon style) */
.comic-image-multi {
padding: 0; /* Remove padding for seamless stacking */
}
.comic-image-multi img {
display: block;
width: 100%;
margin: 0;
padding: 0;
vertical-align: bottom; /* Removes tiny gap below images */
}
/* Lazy-loaded images - placeholder while loading */
.comic-image-multi img.lazy-load {
min-height: 200px;
background: var(--color-hover-bg);
}
.comic-image-multi img.lazy-load.loaded {
min-height: auto;
background: none;
}
/* Comic Navigation */ /* Comic Navigation */
.comic-navigation { .comic-navigation {
border-top: var(--border-width-thin) solid var(--border-color); border-top: var(--border-width-thin) solid var(--border-color);

View File

@@ -5,6 +5,7 @@
let totalComics = 0; let totalComics = 0;
let comicName = ''; // Will be extracted from initial page title let comicName = ''; // Will be extracted from initial page title
let currentComicNumber = 0; let currentComicNumber = 0;
let lazyLoadObserver = null;
// Fetch and display a comic // Fetch and display a comic
async function loadComic(comicId) { async function loadComic(comicId) {
@@ -79,8 +80,15 @@
const comicImageDiv = document.querySelector('.comic-image'); const comicImageDiv = document.querySelector('.comic-image');
updateComicImage(comicImageDiv, comic, title); updateComicImage(comicImageDiv, comic, title);
// Update or create/remove the link wrapper // Update or create/remove the link wrapper (only for single-image comics)
if (!comic.is_multi_image) {
updateComicImageLink(comic.number); updateComicImageLink(comic.number);
}
// Initialize lazy loading for multi-image comics
if (comic.is_multi_image) {
initLazyLoad();
}
// Update author note // Update author note
let transcriptDiv = document.querySelector('.comic-transcript'); let transcriptDiv = document.querySelector('.comic-transcript');
@@ -137,12 +145,72 @@
} }
} }
// Initialize lazy loading for multi-image comics
function initLazyLoad() {
// Disconnect existing observer if any
if (lazyLoadObserver) {
lazyLoadObserver.disconnect();
}
// Create Intersection Observer for lazy loading
lazyLoadObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
img.classList.add('loaded');
lazyLoadObserver.unobserve(img);
}
}
});
}, {
rootMargin: '50px' // Start loading 50px before image enters viewport
});
// Observe all lazy-load images
document.querySelectorAll('.lazy-load').forEach(img => {
lazyLoadObserver.observe(img);
});
}
// Update or create comic image with optional mobile version // Update or create comic image with optional mobile version
function updateComicImage(comicImageDiv, comic, title) { function updateComicImage(comicImageDiv, comic, title) {
// Clear all existing content // Clear all existing content
comicImageDiv.innerHTML = ''; comicImageDiv.innerHTML = '';
// Update container class for multi-image
if (comic.is_multi_image) {
comicImageDiv.classList.add('comic-image-multi');
} else {
comicImageDiv.classList.remove('comic-image-multi');
}
// Create new image element(s) // Create new image element(s)
if (comic.is_multi_image) {
// Multi-image comic (webtoon style)
comic.filenames.forEach((filename, index) => {
const img = document.createElement('img');
if (index === 0) {
// First image loads immediately
img.src = `/static/images/comics/${filename}`;
img.loading = 'eager';
} else {
// Subsequent images lazy load
img.setAttribute('data-src', `/static/images/comics/${filename}`);
img.classList.add('lazy-load');
img.loading = 'lazy';
}
img.alt = comic.alt_texts[index] || '';
img.title = comic.alt_texts[index] || '';
comicImageDiv.appendChild(img);
});
} else {
// Single image comic
if (comic.mobile_filename) { if (comic.mobile_filename) {
// Create picture element with mobile source // Create picture element with mobile source
const picture = document.createElement('picture'); const picture = document.createElement('picture');
@@ -168,6 +236,7 @@
comicImageDiv.appendChild(img); comicImageDiv.appendChild(img);
} }
} }
}
// Update comic image link for click navigation // Update comic image link for click navigation
function updateComicImageLink(currentNumber) { function updateComicImageLink(currentNumber) {
@@ -377,7 +446,17 @@
const dateDisplay = document.querySelector('.comic-date-display'); const dateDisplay = document.querySelector('.comic-date-display');
const formattedDate = dateDisplay ? dateDisplay.textContent : null; const formattedDate = dateDisplay ? dateDisplay.textContent : null;
updateNavButtons(currentNumber, formattedDate); updateNavButtons(currentNumber, formattedDate);
// Check if current comic is multi-image
const comicImageDiv = document.querySelector('.comic-image');
const isMultiImage = comicImageDiv && comicImageDiv.classList.contains('comic-image-multi');
if (!isMultiImage) {
updateComicImageLink(currentNumber); updateComicImageLink(currentNumber);
} else {
// Initialize lazy loading for multi-image comics on page load
initLazyLoad();
}
} }
// Handle browser back/forward // Handle browser back/forward

View File

@@ -37,7 +37,18 @@
</div> </div>
{% endif %} {% endif %}
<div class="comic-image" id="comic-image-focus" tabindex="-1"> <div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
{% if comic.is_multi_image %}
{# Multi-image layout (webtoon style) - no click-through on individual images #}
{% for i in range(comic.filenames|length) %}
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
alt="{{ comic.alt_texts[i] }}"
title="{{ comic.alt_texts[i] }}"
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
{% endfor %}
{% else %}
{# Single image with click-through to next comic #}
{% if comic.number < total_comics %} {% if comic.number < total_comics %}
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic"> <a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic">
{% if comic.mobile_filename %} {% if comic.mobile_filename %}
@@ -69,6 +80,7 @@
loading="eager"> loading="eager">
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %}
</div> </div>
<div class="comic-navigation"> <div class="comic-navigation">

View File

@@ -16,7 +16,17 @@
<p class="comic-date">{{ comic.date }}</p> <p class="comic-date">{{ comic.date }}</p>
</div> </div>
<div class="comic-image" id="comic-image-focus" tabindex="-1"> <div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
{% if comic.is_multi_image %}
{# Multi-image layout (webtoon style) - no click-through on individual images #}
{% for i in range(comic.filenames|length) %}
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
alt="{{ comic.alt_texts[i] }}"
title="{{ comic.alt_texts[i] }}"
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
{% endfor %}
{% else %}
{% if comic.mobile_filename %} {% if comic.mobile_filename %}
<picture> <picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}"> <source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
@@ -29,6 +39,7 @@
alt="{{ comic.title if comic.title else '#' ~ comic.number }}" alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}"> title="{{ comic.alt_text }}">
{% endif %} {% endif %}
{% endif %}
</div> </div>
<div class="comic-navigation"> <div class="comic-navigation">