diff --git a/CLAUDE.md b/CLAUDE.md index f64ce3d..8e05294 100644 --- a/CLAUDE.md +++ b/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) diff --git a/README.md b/README.md index 73d17ea..1c81f88 100644 --- a/README.md +++ b/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: diff --git a/app.py b/app.py index 23a9f08..7baf3bb 100644 --- a/app.py +++ b/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']) diff --git a/static/css/style.css b/static/css/style.css index 8df2f53..a1874ae 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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); diff --git a/static/js/comic-nav.js b/static/js/comic-nav.js index 828fa33..59a1985 100644 --- a/static/js/comic-nav.js +++ b/static/js/comic-nav.js @@ -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 - updateComicImageLink(comic.number); + // 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,35 +145,96 @@ } } + // 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 = ''; - // Create new image element(s) - if (comic.mobile_filename) { - // Create picture element with mobile source - const picture = document.createElement('picture'); - - const source = document.createElement('source'); - source.media = '(max-width: 768px)'; - source.srcset = `/static/images/comics/${comic.mobile_filename}`; - - const img = document.createElement('img'); - img.src = `/static/images/comics/${comic.filename}`; - img.alt = title; - img.title = comic.alt_text; - - picture.appendChild(source); - picture.appendChild(img); - comicImageDiv.appendChild(picture); + // Update container class for multi-image + if (comic.is_multi_image) { + comicImageDiv.classList.add('comic-image-multi'); } else { - // Create regular img element - const img = document.createElement('img'); - img.src = `/static/images/comics/${comic.filename}`; - img.alt = title; - img.title = comic.alt_text; - comicImageDiv.appendChild(img); + 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'); + + const source = document.createElement('source'); + source.media = '(max-width: 768px)'; + source.srcset = `/static/images/comics/${comic.mobile_filename}`; + + const img = document.createElement('img'); + img.src = `/static/images/comics/${comic.filename}`; + img.alt = title; + img.title = comic.alt_text; + + picture.appendChild(source); + picture.appendChild(img); + comicImageDiv.appendChild(picture); + } else { + // Create regular img element + const img = document.createElement('img'); + img.src = `/static/images/comics/${comic.filename}`; + img.alt = title; + img.title = comic.alt_text; + comicImageDiv.appendChild(img); + } } } @@ -377,7 +446,17 @@ const dateDisplay = document.querySelector('.comic-date-display'); const formattedDate = dateDisplay ? dateDisplay.textContent : null; updateNavButtons(currentNumber, formattedDate); - updateComicImageLink(currentNumber); + + // 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 diff --git a/templates/comic.html b/templates/comic.html index a2331e2..ea7f72a 100644 --- a/templates/comic.html +++ b/templates/comic.html @@ -37,36 +37,48 @@ {% endif %} -
{{ comic.date }}