✨ multi-image comics
This commit is contained in:
14
CLAUDE.md
14
CLAUDE.md
@@ -43,9 +43,9 @@ Run this after adding/updating comics to regenerate `static/sitemap.xml` for sea
|
||||
### Data Layer: comics_data.py
|
||||
Comics are stored as a Python list called `COMICS`. Each comic is a dictionary with:
|
||||
- `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
|
||||
- `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)
|
||||
- `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`.
|
||||
@@ -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)
|
||||
- `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`:
|
||||
- `COMIC_NAME`: Your comic/website name
|
||||
- `COPYRIGHT_NAME`: Name to display in copyright notice (defaults to COMIC_NAME if not set)
|
||||
|
||||
114
README.md
114
README.md
@@ -158,6 +158,7 @@ Don't have a server? No problem! Here are beginner-friendly options to get your
|
||||
## Features
|
||||
|
||||
- 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)
|
||||
- Keyboard navigation support (arrow keys, Home/End)
|
||||
- Archive page with thumbnail grid
|
||||
@@ -732,10 +733,10 @@ Comics are stored in the `COMICS` list in `comics_data.py`. Each comic entry:
|
||||
```python
|
||||
{
|
||||
'number': 1, # Comic number (required, sequential)
|
||||
'filename': 'comic-001.png', # Image filename (required)
|
||||
'mobile_filename': 'comic-001-mobile.png', # Optional mobile version
|
||||
'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)
|
||||
'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
|
||||
@@ -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
|
||||
|
||||
**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.
|
||||
|
||||
### 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
|
||||
|
||||
For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods:
|
||||
|
||||
46
app.py
46
app.py
@@ -3,6 +3,7 @@
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template, abort, jsonify, request
|
||||
from comics_data import (
|
||||
@@ -13,6 +14,10 @@ from comics_data import (
|
||||
)
|
||||
import markdown
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Configuration
|
||||
@@ -117,6 +122,47 @@ def enrich_comic(comic):
|
||||
enriched['plain'] = is_plain(comic)
|
||||
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
|
||||
if 'author_note_md' in comic and comic['author_note_md']:
|
||||
markdown_note = get_author_note_from_file(comic['author_note_md'])
|
||||
|
||||
@@ -431,6 +431,30 @@ main {
|
||||
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 {
|
||||
border-top: var(--border-width-thin) solid var(--border-color);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
let totalComics = 0;
|
||||
let comicName = ''; // Will be extracted from initial page title
|
||||
let currentComicNumber = 0;
|
||||
let lazyLoadObserver = null;
|
||||
|
||||
// Fetch and display a comic
|
||||
async function loadComic(comicId) {
|
||||
@@ -79,8 +80,15 @@
|
||||
const comicImageDiv = document.querySelector('.comic-image');
|
||||
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);
|
||||
}
|
||||
|
||||
// Initialize lazy loading for multi-image comics
|
||||
if (comic.is_multi_image) {
|
||||
initLazyLoad();
|
||||
}
|
||||
|
||||
// Update author note
|
||||
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
|
||||
function updateComicImage(comicImageDiv, comic, title) {
|
||||
// Clear all existing content
|
||||
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)
|
||||
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) {
|
||||
// Create picture element with mobile source
|
||||
const picture = document.createElement('picture');
|
||||
@@ -168,6 +236,7 @@
|
||||
comicImageDiv.appendChild(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update comic image link for click navigation
|
||||
function updateComicImageLink(currentNumber) {
|
||||
@@ -377,7 +446,17 @@
|
||||
const dateDisplay = document.querySelector('.comic-date-display');
|
||||
const formattedDate = dateDisplay ? dateDisplay.textContent : null;
|
||||
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);
|
||||
} else {
|
||||
// Initialize lazy loading for multi-image comics on page load
|
||||
initLazyLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle browser back/forward
|
||||
|
||||
@@ -37,7 +37,18 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic">
|
||||
{% if comic.mobile_filename %}
|
||||
@@ -69,6 +80,7 @@
|
||||
loading="eager">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="comic-navigation">
|
||||
|
||||
@@ -16,7 +16,17 @@
|
||||
<p class="comic-date">{{ comic.date }}</p>
|
||||
</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 %}
|
||||
<picture>
|
||||
<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 }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="comic-navigation">
|
||||
|
||||
Reference in New Issue
Block a user