Compare commits

...

49 Commits

Author SHA1 Message Date
mi
631bca7923 📝 initial release docs 2025-11-15 20:53:31 +10:00
mi
6dad2194a5 🔖 initial release 2025-11-15 20:46:44 +10:00
mi
3153455355 📝 consolidate multiple list endpoints for comics 2025-11-15 20:40:18 +10:00
mi
52b80563ba 📝 catch the docs up 2025-11-15 20:21:19 +10:00
mi
61aa0aaba7 :lightning: lazy load archive 2025-11-15 20:01:06 +10:00
mi
bbd8e0a96d :lightning: comics cache 2025-11-15 19:37:52 +10:00
mi
13176c68d2 🎨 manage comics via yaml files 2025-11-15 19:20:21 +10:00
mi
91b6d4efeb multi-image comics 2025-11-15 18:52:14 +10:00
mi
f71720c156 💄 share icons 2025-11-15 18:31:33 +10:00
mi
511c9bee48 🔧 set some defaults 2025-11-15 18:07:00 +10:00
mi
866bfe4d6d permalink option 2025-11-15 18:04:49 +10:00
mi
742ff0e553 comic embed 2025-11-15 16:56:02 +10:00
mi
4ec1feb2a9 comic embed 2025-11-15 16:55:51 +10:00
mi
418ba6e4ba moar anti-AI measures 2025-11-15 15:49:34 +10:00
mi
14415dfcd2 anti-AI measures 2025-11-15 15:43:32 +10:00
mi
1dac042d25 📝 seo 2025-11-15 15:00:05 +10:00
mi
0eccc4b12e 🔍 robots and other optimizations 2025-11-15 14:55:55 +10:00
mi
b23f2399c4 🔍 sitemap 2025-11-15 14:46:49 +10:00
mi
fcb38e593c 🔧 toggle newsletter 2025-11-15 14:37:04 +10:00
mi
b8abcd5566 🤖 ai transparency 2025-11-15 08:51:57 +10:00
mi
8c6481c48c 📝 update link label 2025-11-14 21:14:13 +10:00
mi
83df9d71ac focus styles for archive 2025-11-14 21:01:46 +10:00
mi
4cc7485d4f 📝 update name and subtitle 2025-11-14 20:52:48 +10:00
mi
def4157b7c 📝 table of contents 2025-11-14 20:49:12 +10:00
mi
f07bbc4e1b default to current year for copyright 2025-11-14 20:47:01 +10:00
mi
e9c4423779 🔧 separate option for the copyright name 2025-11-14 20:45:00 +10:00
mi
2a48f00c16 🔧 restore expanded footer 2025-11-14 20:39:29 +10:00
mi
886ac55180 💄 credit image and link 2025-11-14 20:37:38 +10:00
mi
0ce4557df0 💄 hide shareable banner on compact footer 2025-11-14 20:09:39 +10:00
mi
894a609329 shareable banner 2025-11-14 20:07:36 +10:00
mi
4a501757ed 📝 a11y 2025-11-14 19:02:17 +10:00
mi
a0b9bb8bfb label for comic nav 2025-11-14 18:59:13 +10:00
mi
660e4a516f focus management for issues 2025-11-14 18:52:29 +10:00
mi
04fa2073a6 main content link 2025-11-14 18:47:02 +10:00
mi
f31383800e live region 2025-11-14 18:42:59 +10:00
mi
7e41401ca3 announce disabled icons 2025-11-14 18:36:36 +10:00
mi
ff8bdf9a31 unused/confusing icon alt text 2025-11-14 18:31:31 +10:00
mi
733c8f2d32 focus styles 2025-11-14 18:28:27 +10:00
mi
f7c32ca749 🎨 make the markdown file configuration explicit 2025-11-14 16:00:03 +10:00
mi
04cd72b8d8 sections 2025-11-14 15:53:17 +10:00
mi
7a9f64ee17 keyboard nav 2025-11-14 15:43:28 +10:00
mi
ecbc75e447 📝 license 2025-11-14 15:37:41 +10:00
mi
a79f80a2ea 📝 hosting options 2025-11-14 15:30:11 +10:00
mi
6f1a986661 📝 Rarebit comparison 2025-11-14 15:27:15 +10:00
mi
9f7416427e 📝 introduction section 2025-11-14 15:22:10 +10:00
mi
103c283c61 📝 update all the things 2025-11-14 15:17:15 +10:00
mi
0e42cc3f33 🐛 fix content loading for comic 2025-11-14 15:10:50 +10:00
mi
0fb120c54f 🐛 fix content loading for comic nav 2025-11-14 15:07:04 +10:00
mi
e3d4315f7f 💄 site logo 2025-11-13 15:23:38 +10:00
39 changed files with 4426 additions and 266 deletions

4
.gitignore vendored
View File

@@ -3,3 +3,7 @@
# 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
View 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

330
CLAUDE.md Normal file
View File

@@ -0,0 +1,330 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Sunday Comics is a Flask-based webcomic website with server-side rendering and client-side navigation. Comics are stored as individual YAML files in `data/comics/`, making them easy to manage without a database. Each comic gets its own file for clean organization and version control.
## Development Commands
**Run the development server:**
```bash
python app.py
```
Server runs on http://127.0.0.1:3000 by default.
**Enable debug mode:**
```bash
export DEBUG=True
python app.py
```
**Add a new comic:**
```bash
python scripts/add_comic.py
```
This creates a new YAML file in `data/comics/` with the next comic number and reasonable defaults. Edit the generated file to customize title, alt_text, author_note, etc.
**Add a new comic with markdown author note:**
```bash
python scripts/add_comic.py -m
```
This also creates a markdown file in `content/author_notes/` for the author note.
**Generate RSS feed:**
```bash
python scripts/generate_rss.py
```
Run this after adding/updating comics to regenerate `static/feed.rss`.
**Generate sitemap:**
```bash
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
- `data/comics/003.yaml` - Comic #3
- `data/comics/TEMPLATE.yaml` - Template for new comics (ignored by loader)
- `data/comics/README.md` - Documentation for comic files
**Adding a new comic:**
1. Use `python scripts/add_comic.py` to auto-generate the next comic file
2. OR manually copy `TEMPLATE.yaml` and rename it
3. Edit the YAML file to set comic properties
Each comic YAML file contains:
- `number` (required): Sequential comic number
- `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 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`.
- `full_width` (optional): Override global FULL_WIDTH_DEFAULT setting
- `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) in YAML:**
```yaml
filename:
- page1.png
- page2.png
- page3.png
alt_text:
- "Description 1"
- "Description 2"
- "Description 3"
```
- Set `filename` to a list of image filenames
- Set `alt_text` to either a single string (applies to all images) or a list matching each image
- 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)
- `FULL_WIDTH_DEFAULT`: Set to True to make all comics full-width by default
- `PLAIN_DEFAULT`: Set to True to hide header/remove borders by default
- `ARCHIVE_FULL_WIDTH`: Set to True to make archive page full-width with 4 columns
- `SECTIONS_ENABLED`: Set to True to enable section headers on archive page (uses `section` field from comics)
- `HEADER_IMAGE`: Path relative to `static/images/` for site header image (set to None to disable)
- `FOOTER_IMAGE`: Path relative to `static/images/` for site footer image (set to None to disable)
- `BANNER_IMAGE`: Path relative to `static/images/` for shareable banner (set to None to hide "Link to Us" section)
- `COMPACT_FOOTER`: Display footer in compact single-line mode
- `USE_COMIC_NAV_ICONS`: Set to True to use icon images for comic navigation buttons instead of text (requires icons in `static/images/icons/`)
- `USE_HEADER_NAV_ICONS`: Set to True to display icons next to main header navigation text (uses alert.png, archive.png, info.png)
- `USE_FOOTER_SOCIAL_ICONS`: Set to True to use icons instead of text for footer social links (uses instagram.png, youtube.png, mail.png, alert.png)
- `NEWSLETTER_ENABLED`: Set to True to show newsletter section in footer
- `SOCIAL_INSTAGRAM`: Instagram URL (set to None to hide)
- `SOCIAL_YOUTUBE`: YouTube URL (set to None to hide)
- `SOCIAL_EMAIL`: Email mailto link (set to None to hide)
### Markdown Support
**Author Notes:** Add `author_note_md` field to comic entries with a filename (e.g., `"2025-01-01.md"`) or path relative to `content/` (e.g., `"special/note.md"`). Just a filename looks in `content/author_notes/`. The markdown content is rendered as HTML and takes precedence over the plain text `author_note` field.
**About Page:** The `/about` route renders `content/about.md` as HTML using the markdown library.
### Flask Application: app.py
**Core Functions:**
- `enrich_comic(comic)`: Adds computed properties (full_width, plain, formatted_date, author_note from markdown)
- `get_comic_by_number(number)`: Retrieves and enriches a comic by number
- `get_latest_comic()`: Returns the last comic in the COMICS list
- `format_comic_date(date_str)`: Converts YYYY-MM-DD to "Day name, Month name day, year"
- `get_author_note_from_file(filename)`: Loads markdown author note from file. Filename alone looks in `content/author_notes/`, paths are relative to `content/`.
- `group_comics_by_section(comics_list)`: Groups comics by section based on `section` field. Returns list of (section_title, comics) tuples.
**Routes:**
- `/` - Latest comic (index.html)
- `/comic/<id>` - Specific comic viewer (comic.html)
- `/archive` - Grid of all comics, newest first (archive.html)
- `/about` - Renders markdown from content/about.md (page.html)
- `/api/comics` - JSON array of all comics
- `/api/comics/<id>` - JSON for a specific comic
### Client-Side Navigation: static/js/comic-nav.js
Provides SPA-like navigation without page reloads:
- Fetches comics from `/api/comics/<id>`
- Updates DOM with `displayComic(comic)` function
- Handles navigation buttons and image click-through
- Uses History API to maintain proper URLs and browser back/forward
- Shows/hides header based on plain mode
- Adjusts container for full_width mode
- Updates author notes dynamically (plain text only; markdown rendering requires page reload)
**Note:** Client-side navigation displays author notes as plain text. Markdown author notes only render properly on initial page load (server-side).
### Templates
Built with Jinja2, extending `base.html`:
- `base.html`: Contains navigation header, footer, metadata tags (Open Graph, Twitter Cards)
- `index.html` & `comic.html`: Display comics with navigation buttons
- `archive.html`: Grid layout with thumbnails from `static/images/thumbs/`
- `page.html`: Generic template for markdown content (used by /about)
Global context variables injected into all templates:
- `comic_name`: Site/comic name from `comics_data.py`
- `copyright_name`: Copyright name from `comics_data.py` (defaults to `comic_name` if not set)
- `current_year`: Current year (auto-generated from system time)
- `header_image`: Site header image path from `comics_data.py`
- `footer_image`: Site footer image path from `comics_data.py`
- `banner_image`: Shareable banner image path from `comics_data.py`
- `compact_footer`: Boolean for footer style from `comics_data.py`
- `use_comic_nav_icons`: Boolean for comic navigation icons from `comics_data.py`
- `use_header_nav_icons`: Boolean for main header navigation icons from `comics_data.py`
- `use_footer_social_icons`: Boolean for footer social link icons from `comics_data.py`
- `newsletter_enabled`: Boolean to show/hide newsletter section from `comics_data.py`
- `social_instagram`: Instagram URL from `comics_data.py`
- `social_youtube`: YouTube URL from `comics_data.py`
- `social_email`: Email link from `comics_data.py`
## Static Assets
- `static/images/comics/`: Full-size comic images
- `static/images/thumbs/`: Thumbnails for archive page (optional, same filename as comic)
- `static/images/icons/`: Navigation icons (first.png, previous.png, next.png, latest.png) used when `USE_ICON_NAV` is True
- `static/images/`: Header images and other site graphics
- `static/feed.rss`: Generated RSS feed (run `scripts/generate_rss.py`)
- `static/sitemap.xml`: Generated sitemap (run `scripts/generate_sitemap.py`)
## 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. 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.
3. **Enrichment pattern**: Always use `enrich_comic()` before passing comics to templates or APIs. This adds computed properties like `full_width`, `plain`, and `formatted_date`.
4. **Date formatting**: The `format_comic_date()` function uses `%d` with lstrip('0') for cross-platform compatibility (not all systems support `%-d`).
5. **Author notes hierarchy**: If `author_note_md` field is specified, the markdown file is loaded and rendered as HTML, taking precedence over the plain text `author_note` field. When markdown is used, `author_note_is_html` is set to True.
6. **Settings cascade**: Global settings (FULL_WIDTH_DEFAULT, PLAIN_DEFAULT) apply unless overridden per-comic with `full_width` or `plain` keys in the YAML file.
7. **Navigation state**: Client-side navigation reads `data-total-comics` and `data-comic-number` from the `.comic-container` element to manage button states.
8. **Comic icon navigation**: When `USE_COMIC_NAV_ICONS` is True, templates use `.btn-icon-nav` class with icon images instead of text buttons. JavaScript automatically detects icon mode and applies appropriate classes. Disabled icons have reduced opacity (0.3).
9. **Archive sections**: When `SECTIONS_ENABLED` is True, comics with a `section` field will start a new section on the archive page. Only add the `section` field to the first comic of each new section. All subsequent comics belong to that section until a new `section` field is encountered.
10. **YAML validation**: The data loader validates each comic file and logs warnings for missing required fields (`number`, `filename`, `date`, `alt_text`). Invalid files are skipped.
## Production Deployment
The app uses Flask's development server by default. For production:
**Recommended: Docker**
```bash
docker-compose up -d
```
**Alternative: Gunicorn**
```bash
pip install gunicorn
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')"
export DEBUG=False
gunicorn app:app --bind 0.0.0.0:3000 --workers 4
```
Environment variables:
- `SECRET_KEY`: Flask secret key (generate with `secrets.token_hex(32)`)
- `PORT`: Server port (default: 3000)
- `DEBUG`: Debug mode (default: False)
## Accessibility Implementation
Sunday Comics follows WCAG 2.1 Level AA guidelines. When modifying the site, maintain these accessibility features:
### Keyboard Navigation
- **Focus indicators**: All interactive elements have visible 3px outlines when focused (defined in `static/css/style.css`)
- **Skip to main content**: First focusable element on every page, appears at top when focused
- **Keyboard shortcuts**: Arrow keys (Left/Right), Home, End for comic navigation (handled in `static/js/comic-nav.js`)
- **Focus management**: After navigation, focus programmatically moves to `#comic-image-focus` element
### Screen Reader Support
- **ARIA live region**: `#comic-announcer` element announces comic changes with `aria-live="polite"`
- **ARIA labels**: All icon buttons have descriptive `aria-label` attributes
- **ARIA disabled**: Disabled navigation buttons include `aria-disabled="true"`
- **Empty alt text**: Decorative icons (next to text labels) use `alt=""` to prevent redundant announcements
- **Clickable images**: Links wrapping comic images have `aria-label="Click to view next comic"`
### Semantic HTML
- Proper heading hierarchy (h1 → h2 → h3)
- `lang="en"` on html element
- Semantic elements: `<header>`, `<nav>`, `<main>`, `<footer>`
- `tabindex="-1"` on programmatically focusable elements (not in tab order)
### CSS Accessibility
- `.sr-only` class: Hides content visually but keeps it accessible to screen readers
- `.skip-to-main` class: Positioned off-screen until focused, then slides into view
- Focus styles use `outline` property (never remove without replacement)
- Disabled buttons use both color and opacity for visibility
### JavaScript Accessibility
The `comic-nav.js` file handles:
1. **Announcements**: Updates `#comic-announcer` text content on navigation
2. **Focus**: Calls `.focus()` on `#comic-image-focus` after loading comic
3. **ARIA attributes**: Dynamically adds/removes `aria-disabled` on navigation buttons
4. **Boundary feedback**: Announces "Already at first/latest comic" at navigation limits
### Maintaining Accessibility
When adding features:
- Ensure all images have meaningful alt text
- Test with keyboard only (no mouse)
- Verify focus indicators are visible
- Check ARIA labels on icon buttons
- Test with screen readers when possible (VoiceOver, NVDA)
- Maintain semantic HTML structure
## Testing Approach
No test suite currently exists. When adding tests, consider:
- Comic retrieval and enrichment logic
- API endpoint responses
- Date formatting edge cases
- Markdown rendering for author notes and about page
- Accessibility: keyboard navigation, ARIA attributes, focus management

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Tomasita Cabrera
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1026
README.md

File diff suppressed because it is too large Load Diff

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2025.11.15

339
app.py
View File

@@ -1,13 +1,23 @@
# Sunday Comics - A simple webcomic platform
# Copyright (c) 2025 Tomasita Cabrera
# 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 (
COMICS, COMIC_NAME, SITE_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, HEADER_IMAGE, FOOTER_IMAGE,
COMPACT_FOOTER, ARCHIVE_FULL_WIDTH, USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS,
USE_FOOTER_SOCIAL_ICONS, SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL,
API_SPEC_LINK
COMICS, COMIC_NAME, COPYRIGHT_NAME, SITE_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, LOGO_IMAGE, LOGO_MODE,
HEADER_IMAGE, FOOTER_IMAGE, BANNER_IMAGE, COMPACT_FOOTER, ARCHIVE_FULL_WIDTH, SECTIONS_ENABLED,
USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS, USE_FOOTER_SOCIAL_ICONS, USE_SHARE_ICONS, NEWSLETTER_ENABLED,
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)
logger = logging.getLogger(__name__)
app = Flask(__name__)
@@ -15,23 +25,41 @@ app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key')
@app.after_request
def add_ai_blocking_headers(response):
"""Add headers to discourage AI scraping"""
response.headers['X-Robots-Tag'] = 'noai, noimageai'
return response
@app.context_processor
def inject_global_settings():
"""Make global settings available to all templates"""
return {
'comic_name': COMIC_NAME,
'copyright_name': COPYRIGHT_NAME if COPYRIGHT_NAME else COMIC_NAME,
'current_year': datetime.now().year,
'site_url': SITE_URL,
'logo_image': LOGO_IMAGE,
'logo_mode': LOGO_MODE,
'header_image': HEADER_IMAGE,
'footer_image': FOOTER_IMAGE,
'banner_image': BANNER_IMAGE,
'compact_footer': COMPACT_FOOTER,
'archive_full_width': ARCHIVE_FULL_WIDTH,
'sections_enabled': SECTIONS_ENABLED,
'use_comic_nav_icons': USE_COMIC_NAV_ICONS,
'use_header_nav_icons': USE_HEADER_NAV_ICONS,
'use_footer_social_icons': USE_FOOTER_SOCIAL_ICONS,
'use_share_icons': USE_SHARE_ICONS,
'newsletter_enabled': NEWSLETTER_ENABLED,
'social_instagram': SOCIAL_INSTAGRAM,
'social_youtube': SOCIAL_YOUTUBE,
'social_email': SOCIAL_EMAIL,
'api_spec_link': API_SPEC_LINK
'api_spec_link': API_SPEC_LINK,
'embed_enabled': EMBED_ENABLED,
'permalink_enabled': PERMALINK_ENABLED,
'version': __version__
}
@@ -65,9 +93,20 @@ def format_comic_date(date_str):
return date_str
def get_author_note(date_str):
"""Load author note from markdown file if it exists, using date as filename"""
note_path = os.path.join(os.path.dirname(__file__), 'content', 'author_notes', f'{date_str}.md')
def get_author_note_from_file(filename):
"""Load author note from markdown file if it exists
Args:
filename: Either just a filename (looked up in content/author_notes/)
or a path relative to content/
"""
# If filename contains a path separator, treat as relative to content/
if '/' in filename or '\\' in filename:
note_path = os.path.join(os.path.dirname(__file__), 'content', filename)
else:
# Just a filename, look in author_notes directory
note_path = os.path.join(os.path.dirname(__file__), 'content', 'author_notes', filename)
try:
with open(note_path, 'r', encoding='utf-8') as f:
content = f.read()
@@ -85,13 +124,58 @@ def enrich_comic(comic):
enriched['plain'] = is_plain(comic)
enriched['formatted_date'] = format_comic_date(comic['date'])
# Check for markdown author note, fall back to data field if not found
markdown_note = get_author_note(comic['date'])
if markdown_note:
enriched['author_note'] = markdown_note
enriched['author_note_is_html'] = True
# Normalize filename to list for multi-image support
if isinstance(comic.get('filename'), list):
enriched['filenames'] = comic['filename']
enriched['is_multi_image'] = True
else:
# No markdown file, use plain text from comic data if it exists
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'])
if markdown_note:
enriched['author_note'] = markdown_note
enriched['author_note_is_html'] = True
else:
# File specified but not found, use plain text from comic data if it exists
enriched['author_note_is_html'] = False
else:
# No markdown file specified, use plain text from comic data if it exists
enriched['author_note_is_html'] = False
return enriched
@@ -129,17 +213,73 @@ def comic(comic_id):
comic = get_comic_by_number(comic_id)
if not comic:
abort(404)
return render_template('comic.html', title=f"Comic #{comic_id}",
# Use comic title if present, otherwise use #X format (matching client-side behavior)
page_title = comic.get('title', f"#{comic_id}")
return render_template('comic.html', title=page_title,
comic=comic, total_comics=len(COMICS))
@app.route('/embed/<int:comic_id>')
def embed(comic_id):
"""Embeddable comic view - minimal layout for iframes"""
if not EMBED_ENABLED:
abort(404)
comic = get_comic_by_number(comic_id)
if not comic:
abort(404)
# Use comic title if present, otherwise use #X format
page_title = comic.get('title', f"#{comic_id}")
return render_template('embed.html', title=page_title, comic=comic)
def group_comics_by_section(comics_list):
"""Group comics by section. Returns list of (section_title, comics) tuples"""
if not SECTIONS_ENABLED:
return [(None, comics_list)]
sections = []
current_section = None
current_comics = []
for comic in comics_list:
# Check if this comic starts a new section
if 'section' in comic:
# Save previous section if it has comics
if current_comics:
sections.append((current_section, current_comics))
# Start new section
current_section = comic['section']
current_comics = [comic]
else:
# Add to current section
current_comics.append(comic)
# Don't forget the last section
if current_comics:
sections.append((current_section, current_comics))
return sections
@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(initial_comics)
return render_template('archive.html', title='Archive',
comics=comics)
sections=sections,
total_comics=len(COMICS),
initial_batch=initial_batch)
@app.route('/about')
@@ -156,10 +296,93 @@ def about():
return render_template('page.html', title='About', content=html_content)
@app.route('/terms')
def terms():
"""Terms of Service page"""
from jinja2 import Template
# Read and render the markdown file with template variables
terms_path = os.path.join(os.path.dirname(__file__), 'content', 'terms.md')
try:
with open(terms_path, 'r', encoding='utf-8') as f:
content = f.read()
# First render as Jinja template to substitute variables
template = Template(content)
rendered_content = template.render(
copyright_name=COPYRIGHT_NAME,
social_email=SOCIAL_EMAIL if SOCIAL_EMAIL else '[Contact Email]'
)
# Then convert markdown to HTML
html_content = markdown.markdown(rendered_content)
except FileNotFoundError:
html_content = '<p>Terms of Service content not found.</p>'
return render_template('page.html', title='Terms of Service', content=html_content)
@app.route('/api/comics')
def api_comics():
"""API endpoint - returns all comics as JSON"""
return jsonify([enrich_comic(comic) for comic in COMICS])
"""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>')
@@ -171,6 +394,84 @@ def api_comic(comic_id):
return jsonify(comic)
@app.route('/sitemap.xml')
def sitemap():
"""Serve the static sitemap.xml file"""
from flask import send_from_directory
return send_from_directory('static', 'sitemap.xml', mimetype='application/xml')
@app.route('/robots.txt')
def robots():
"""Generate robots.txt dynamically with correct SITE_URL"""
from flask import Response
robots_txt = f"""# Sunday Comics - Robots.txt
# Content protected by copyright. AI training prohibited.
# See terms: {SITE_URL}/terms
User-agent: *
Allow: /
# Sitemap location
Sitemap: {SITE_URL}/sitemap.xml
# Disallow API endpoints from indexing
Disallow: /api/
# Block AI crawlers and scrapers
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: Claude-Web
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: PerplexityBot
Disallow: /
User-agent: Omgilibot
Disallow: /
User-agent: Diffbot
Disallow: /
User-agent: Bytespider
Disallow: /
User-agent: FacebookBot
Disallow: /
User-agent: ImagesiftBot
Disallow: /
User-agent: cohere-ai
Disallow: /
"""
return Response(robots_txt, mimetype='text/plain')
@app.route('/tdmrep.json')
def tdm_reservation():
"""TDM (Text and Data Mining) reservation - signals AI training prohibition"""
return jsonify({
"tdm": {
"reservation": 1,
"policy": f"{SITE_URL}/terms"
}
})
@app.errorhandler(404)
def page_not_found(e):
"""404 error handler"""

View File

@@ -1,9 +1,16 @@
# Comic data
# Sunday Comics - Comic data configuration
# Copyright (c) 2025 Tomasita Cabrera
# Licensed under the MIT License - see LICENSE file for details
#
# Edit this file to add, remove, or modify comics
# Global setting: The name of your comic/website
COMIC_NAME = 'Sunday Comics'
# Global setting: The name to display in the copyright notice
# If not set (None), defaults to COMIC_NAME
COPYRIGHT_NAME = None # e.g., 'Your Name' or 'Your Studio Name'
# Global setting: Your website's domain (used for RSS feed, Open Graph tags, etc.)
# Update this to your production domain when deploying
SITE_URL = 'http://localhost:3000'
@@ -16,6 +23,17 @@ FULL_WIDTH_DEFAULT = False
# Individual comics can override this with 'plain': False
PLAIN_DEFAULT = False
# Global setting: Path to site logo (relative to static/images/)
# Set to None to disable logo
# Example: LOGO_IMAGE = 'logo.png' will use static/images/logo.png
LOGO_IMAGE = 'logo.png'
# Global setting: Logo display mode
# 'beside' - Display logo next to the site title
# 'replace' - Replace the site title with the logo
# Only applies when LOGO_IMAGE is set
LOGO_MODE = 'beside'
# Global setting: Path to header image (relative to static/images/)
# Set to None to disable header image
# Example: HEADER_IMAGE = 'title.jpg' will use static/images/title.jpg
@@ -24,7 +42,12 @@ HEADER_IMAGE = None
# Global setting: Path to footer image (relative to static/images/)
# Set to None to disable footer image
# Example: FOOTER_IMAGE = 'footer.jpg' will use static/images/footer.jpg
FOOTER_IMAGE = None # 'footer.jpg'
FOOTER_IMAGE = None
# Global setting: Path to shareable banner image (relative to static/images/)
# Set to None to disable "Link to Us" section in footer
# Example: BANNER_IMAGE = 'banner.jpg' will use static/images/banner.jpg
BANNER_IMAGE = 'banner.jpg'
# Global setting: Set to True to display footer in compact mode
# Compact mode: single line, no border, horizontal layout
@@ -34,6 +57,10 @@ COMPACT_FOOTER = False
# Full-width archive shows square thumbnails with only dates, no titles
ARCHIVE_FULL_WIDTH = True
# Global setting: Set to True to enable sections/chapters on the archive page
# Add 'section': 'Chapter Title' to comics where a new section starts
SECTIONS_ENABLED = True
# Global setting: Set to True to use icon images for comic navigation buttons
# Icons should be in static/images/icons/ (first.png, previous.png, next.png, latest.png)
USE_COMIC_NAV_ICONS = True
@@ -46,6 +73,13 @@ USE_HEADER_NAV_ICONS = True
# Uses instagram.png, youtube.png, and mail.png from static/images/icons/
USE_FOOTER_SOCIAL_ICONS = True
# Global setting: Set to True to show icons in share buttons (permalink and embed)
# Uses link.png for permalink and embed.png for embed from static/images/icons/
USE_SHARE_ICONS = True
# Global setting: Set to True to show newsletter section in footer
NEWSLETTER_ENABLED = False
# Social media links - set to None to hide the link
SOCIAL_INSTAGRAM = None # e.g., 'https://instagram.com/yourhandle'
SOCIAL_YOUTUBE = None # e.g., 'https://youtube.com/@yourchannel'
@@ -55,32 +89,25 @@ SOCIAL_EMAIL = None # e.g., 'mailto:your@email.com'
# Path is relative to static/ directory
API_SPEC_LINK = None # Set to 'openapi.yml' to enable
COMICS = [
{
'number': 1,
'title': 'First Comic',
'filename': 'comic-001.jpg',
'mobile_filename': 'comic-001-mobile.jpg', # Optional: mobile version of the comic
'date': '2025-01-01',
'alt_text': 'The very first comic',
'author_note': 'This is where your comic journey begins!',
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT for this comic
'plain': True, # Optional: override PLAIN_DEFAULT for this comic
},
{
'number': 2,
'filename': 'comic-002.jpg',
'date': '2025-01-08',
'alt_text': 'The second comic',
'full_width': True,
'plain': True,
},
{
'number': 3,
'title': 'Third Comic',
'filename': 'comic-003.jpg',
'date': '2025-01-15',
'alt_text': 'The third comic',
'author_note': 'Things are getting interesting!',
},
]
# Global setting: Set to True to enable comic embed functionality
# When enabled, users can get embed codes to display comics on other websites
EMBED_ENABLED = True
# Global setting: Set to True to enable permalink copy button
# When enabled, users can easily copy a direct link to the current comic
PERMALINK_ENABLED = True
# Load comics from YAML files
from data_loader import load_comics_from_yaml, validate_comics
COMICS = load_comics_from_yaml('data/comics')
# Validate loaded comics
if not validate_comics(COMICS):
print("Warning: Comic validation failed. Please check your YAML files.")
# Show loaded comics count
if COMICS:
print(f"Loaded {len(COMICS)} comics from data/comics/")
else:
print("Warning: No comics loaded! Please add .yaml files to data/comics/")

93
content/terms.md Normal file
View File

@@ -0,0 +1,93 @@
# Terms of Service
**Last Updated:** January 2025
By accessing and using this website, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use this site.
## Copyright and Ownership
All comics, artwork, text, graphics, and other content on this website are protected by copyright and owned by {{ copyright_name }}. All rights reserved.
## Permitted Use
**Personal Use:** You may:
- Read and enjoy the comics for personal, non-commercial purposes
- Share links to individual comic pages on social media
- Embed comics on personal websites with proper attribution and a link back to the original
**Attribution Required:** When sharing or embedding, you must:
- Provide clear credit to {{ copyright_name }}
- Include a link back to this website
- Not alter, crop, or modify the comic images
## Prohibited Use
You are **expressly prohibited** from:
### AI Training and Machine Learning
- Using any content from this site for training artificial intelligence models
- Scraping, crawling, or harvesting content for machine learning purposes
- Including any images, text, or data in AI training datasets
- Using content to develop, train, or improve generative AI systems
- Creating derivative works using AI trained on this content
### Commercial Use
- Reproducing, distributing, or selling comics without explicit written permission
- Using comics or artwork for commercial purposes without a license
- Printing comics on merchandise (t-shirts, mugs, etc.) without authorization
### Modification and Redistribution
- Altering, editing, or creating derivative works from the comics
- Removing watermarks, signatures, or attribution
- Rehosting images on other servers or websites
- Claiming comics as your own work
## Data Mining and Web Scraping
**Automated Access Prohibition:** Automated scraping, crawling, or systematic downloading of content is strictly prohibited without prior written consent. This includes but is not limited to:
- Web scrapers and bots (except authorized search engines)
- Automated downloads of images or data
- RSS feed abuse or bulk downloading
- Any form of data harvesting for commercial purposes
**Text and Data Mining (TDM) Reservation:** We formally reserve all rights under applicable copyright law regarding text and data mining, including but not limited to EU Directive 2019/790 Article 4. No TDM exceptions apply to this content.
## DMCA and Copyright Enforcement
Unauthorized use of copyrighted material from this site may violate copyright law and be subject to legal action under the Digital Millennium Copyright Act (DMCA) and other applicable laws.
If you discover unauthorized use of content from this site, please report it to {{ social_email }}.
## Fair Use
Limited use for purposes of commentary, criticism, news reporting, teaching, or research may qualify as fair use. If you believe your use qualifies as fair use, please contact us first.
## License Requests
If you wish to use content in ways not permitted by these terms, please contact us to discuss licensing arrangements.
## Privacy
We respect your privacy. This site may use cookies for basic functionality and analytics. We do not sell personal information to third parties.
## External Links
This site may contain links to external websites. We are not responsible for the content or practices of third-party sites.
## Modifications to Terms
We reserve the right to modify these Terms of Service at any time. Changes will be posted on this page with an updated "Last Updated" date.
## Contact
For questions about these terms, licensing requests, or to report copyright violations:
{{ social_email }}
## Governing Law
These Terms of Service are governed by applicable copyright law and the laws of [Your Jurisdiction].
---
**Summary:** You can read and share links to comics, but you cannot use them for AI training, scrape the site, use them commercially, or create modified versions without permission.

23
data/comics/001.yaml Normal file
View File

@@ -0,0 +1,23 @@
# Comic #1
number: 1
title: "First Comic"
filename: comic-001.jpg
mobile_filename: comic-001-mobile.jpg # Optional: mobile version of the comic
date: "2025-01-01"
alt_text: "The very first comic"
# Author notes (choose one method):
# Option 1: Plain text note
author_note: "This is where your comic journey begins!"
# Option 2: Markdown file (overrides author_note if present)
# Just a filename looks in content/author_notes/
# Or use a path like "special/note.md" relative to content/
author_note_md: "2025-01-01.md"
# Display settings (override global defaults)
full_width: true
plain: true
# Section header (optional - only add to first comic of a new section)
section: "Chapter 1: The Beginning"

9
data/comics/002.yaml Normal file
View File

@@ -0,0 +1,9 @@
# Comic #2
number: 2
filename: comic-002.jpg
date: "2025-01-08"
alt_text: "The second comic"
# Display settings
full_width: true
plain: true

7
data/comics/003.yaml Normal file
View File

@@ -0,0 +1,7 @@
# Comic #3
number: 3
title: "Third Comic"
filename: comic-003.jpg
date: "2025-01-15"
alt_text: "The third comic"
author_note: "Things are getting interesting!"

89
data/comics/README.md Normal file
View File

@@ -0,0 +1,89 @@
# Comic Data Directory
This directory contains YAML files for managing individual comics. Each comic gets its own `.yaml` file.
## Quick Start
### Adding a New Comic
1. **Copy the template:**
```bash
cp TEMPLATE.yaml 004.yaml
```
2. **Edit the file** with your comic's information:
- Update `number`, `filename`, `date`, and `alt_text` (required)
- Add optional fields like `title`, `author_note`, etc.
3. **Save the file** and restart your application
The comics will be automatically loaded and sorted by comic number.
## File Naming
You can name files anything you want (e.g., `001.yaml`, `first-comic.yaml`, `2025-01-01.yaml`), but using the comic number is recommended for easy organization.
## Required Fields
Every comic MUST have:
- `number` - Sequential comic number (integer)
- `filename` - Image filename (string) or list of filenames for multi-image comics
- `date` - Publication date in YYYY-MM-DD format (string)
- `alt_text` - Accessibility description (string or list for multi-image)
## Optional Fields
- `title` - Comic title (defaults to "#X" if not provided)
- `mobile_filename` - Mobile-optimized version
- `author_note` - Plain text note below the comic
- `author_note_md` - Markdown file for author note (overrides `author_note`)
- `full_width` - Override global width setting (boolean)
- `plain` - Override global plain mode (boolean)
- `section` - Start a new section/chapter (string, add only to first comic of section)
## Multi-Image Comics (Webtoon Style)
For vertical scrolling comics with multiple images:
```yaml
number: 42
filename:
- page1.png
- page2.png
- page3.png
alt_text:
- "First panel description"
- "Second panel description"
- "Third panel description"
date: "2025-01-01"
```
## Example
```yaml
number: 4
title: "The Adventure Begins"
filename: comic-004.jpg
date: "2025-01-22"
alt_text: "A hero stands at the edge of a cliff, looking at the horizon"
author_note: "This is where things get interesting!"
full_width: true
section: "Chapter 2: The Journey"
```
## Validation
The data loader will:
- Skip files with missing required fields (with warnings)
- Check for duplicate comic numbers
- Warn about gaps in numbering
- Sort comics by number automatically
## Testing Your Changes
Test the loader directly:
```bash
python data_loader.py
```
This will show you all loaded comics and any validation warnings.

51
data/comics/TEMPLATE.yaml Normal file
View File

@@ -0,0 +1,51 @@
# Template for creating new comics
# Copy this file and rename it to match your comic number (e.g., 004.yaml, 005.yaml)
# Fields marked as REQUIRED must be included
# All other fields are optional
# REQUIRED: Sequential comic number
number: 999
# REQUIRED: Image filename(s) in static/images/comics/
# Single image:
filename: comic-999.jpg
# OR multi-image (webtoon style):
# filename:
# - page1.png
# - page2.png
# - page3.png
# Optional: Mobile-optimized version of the comic
# mobile_filename: comic-999-mobile.jpg
# REQUIRED: Publication date (YYYY-MM-DD format)
date: "2025-01-01"
# REQUIRED: Accessibility text for screen readers
# Single alt text (for single or multi-image):
alt_text: "Description of what happens in this comic"
# OR individual alt texts for multi-image comics:
# alt_text:
# - "Description of first image"
# - "Description of second image"
# - "Description of third image"
# Optional: Comic title (defaults to "#X" if not provided)
title: "Title of Your Comic"
# Optional: Plain text author note
author_note: "Your thoughts about this comic."
# Optional: Markdown author note file (overrides author_note if present)
# Just filename looks in content/author_notes/
# Or use path like "special/note.md" relative to content/
# author_note_md: "2025-01-01.md"
# Optional: Override global FULL_WIDTH_DEFAULT setting
# full_width: true
# Optional: Override global PLAIN_DEFAULT setting (hides header/border)
# plain: true
# Optional: Section/chapter title (only add to first comic of a new section)
# section: "Chapter 2: New Adventures"

179
data_loader.py Normal file
View File

@@ -0,0 +1,179 @@
"""
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', use_cache=True):
"""
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_path = Path(comics_dir)
if not comics_path.exists():
print(f"Warning: Comics directory '{comics_dir}' does not exist. Creating it...")
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'))
# Filter out template and README files
yaml_files = [f for f in yaml_files if f.stem.upper() not in ('TEMPLATE', 'README')]
if not yaml_files:
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:
comic_data = yaml.safe_load(f)
if comic_data is None:
print(f"Warning: '{yaml_file.name}' is empty, skipping")
continue
if 'number' not in comic_data:
print(f"Warning: '{yaml_file.name}' missing required 'number' field, skipping")
continue
if 'filename' not in comic_data:
print(f"Warning: '{yaml_file.name}' missing required 'filename' field, skipping")
continue
if 'date' not in comic_data:
print(f"Warning: '{yaml_file.name}' missing required 'date' field, skipping")
continue
if 'alt_text' not in comic_data:
print(f"Warning: '{yaml_file.name}' missing required 'alt_text' field, skipping")
continue
comics.append(comic_data)
except yaml.YAMLError as e:
print(f"Error parsing '{yaml_file.name}': {e}")
continue
except Exception as e:
print(f"Error loading '{yaml_file.name}': {e}")
continue
# 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.
Args:
comics: List of comic dictionaries
Returns:
True if validation passes, False otherwise
"""
if not comics:
return True
numbers = [c['number'] for c in comics]
# Check for duplicate comic numbers
if len(numbers) != len(set(numbers)):
duplicates = [n for n in numbers if numbers.count(n) > 1]
print(f"Warning: Duplicate comic numbers found: {set(duplicates)}")
return False
# Check for gaps in comic numbering (optional warning)
for i in range(len(comics) - 1):
if comics[i+1]['number'] - comics[i]['number'] > 1:
print(f"Info: Gap in comic numbering between {comics[i]['number']} and {comics[i+1]['number']}")
return True
if __name__ == '__main__':
# Test the loader
print("Loading comics from data/comics/...")
comics = load_comics_from_yaml()
print(f"Loaded {len(comics)} comics")
if validate_comics(comics):
print("Validation passed!")
for comic in comics:
title = comic.get('title', f"#{comic['number']}")
print(f" - Comic {comic['number']}: {title} ({comic['date']})")
else:
print("Validation failed!")

View File

@@ -1,2 +1,3 @@
Flask==3.0.0
markdown==3.5.1
PyYAML==6.0.3

View File

@@ -1,6 +1,10 @@
#!/usr/bin/env python3
# Sunday Comics - Add comic script
# Copyright (c) 2025 Tomasita Cabrera
# Licensed under the MIT License - see LICENSE file for details
"""
Script to add a new comic entry to comics_data.py with reasonable defaults
Script to add a new comic entry as a YAML file with reasonable defaults
"""
import sys
import os
@@ -47,52 +51,83 @@ Write your author note here using markdown formatting.
def main():
"""Add a new comic entry with defaults"""
parser = argparse.ArgumentParser(description='Add a new comic entry to comics_data.py')
parser = argparse.ArgumentParser(description='Add a new comic entry as a YAML file')
parser.add_argument('-m', '--markdown', action='store_true',
help='Generate a markdown file for author notes')
help='Generate a markdown file for author notes and add author_note_md field to comic entry')
args = parser.parse_args()
# Get next number
number = max(comic['number'] for comic in COMICS) + 1 if COMICS else 1
# Get today's date
date_str = datetime.now().strftime('%Y-%m-%d')
# Create entry with defaults
comic = {
comic_data = {
'number': number,
'filename': f'comic-{number:03d}.png',
'date': datetime.now().strftime('%Y-%m-%d'),
'date': date_str,
'alt_text': f'Comic #{number}',
}
# Get path to comics_data.py
# Add markdown reference if requested
if args.markdown:
comic_data['author_note_md'] = f'{date_str}.md'
# Get paths
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(script_dir)
comics_file = os.path.join(parent_dir, 'comics_data.py')
comics_dir = os.path.join(parent_dir, 'data', 'comics')
yaml_file = os.path.join(comics_dir, f'{number:03d}.yaml')
# Read file
with open(comics_file, 'r') as f:
content = f.read()
# Create comics directory if it doesn't exist
os.makedirs(comics_dir, exist_ok=True)
# Format new entry
entry_str = f""" {{
'number': {comic['number']},
'filename': {repr(comic['filename'])},
'date': {repr(comic['date'])},
'alt_text': {repr(comic['alt_text'])}
}}"""
# Check if file already exists
if os.path.exists(yaml_file):
print(f"Error: Comic file already exists: {yaml_file}")
sys.exit(1)
# Insert before closing bracket
insert_pos = content.rfind(']')
new_content = content[:insert_pos] + entry_str + ",\n" + content[insert_pos:]
# Create YAML file with comments
yaml_content = f"""# Comic #{number}
number: {number}
filename: {comic_data['filename']}
date: "{date_str}"
alt_text: "{comic_data['alt_text']}"
"""
# Write back
with open(comics_file, 'w') as f:
f.write(new_content)
if args.markdown:
yaml_content += f'\n# Markdown author note (overrides author_note if present)\nauthor_note_md: "{date_str}.md"\n'
else:
yaml_content += '\n# Optional: Add author note\n# author_note: "Your thoughts about this comic."\n'
print(f"Added comic #{number}")
yaml_content += """
# Optional: Add a title
# title: "Title of Your Comic"
# Optional: Override global settings
# full_width: true
# plain: true
# Optional: Start a new section (only add to first comic of section)
# section: "Chapter X: Title"
"""
# Write YAML file
with open(yaml_file, 'w') as f:
f.write(yaml_content)
print(f"Created comic #{number}: {yaml_file}")
# Create markdown file if requested
if args.markdown:
create_markdown_file(comic['date'], parent_dir)
create_markdown_file(date_str, parent_dir)
print(f"\nNext steps:")
print(f"1. Add your comic image as: static/images/comics/{comic_data['filename']}")
print(f"2. Edit {yaml_file} to customize the comic metadata")
if args.markdown:
print(f"3. Edit content/author_notes/{date_str}.md to write your author note")
if __name__ == '__main__':

154
scripts/bump_version.py Executable file
View 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()

View File

@@ -1,4 +1,8 @@
#!/usr/bin/env python3
# Sunday Comics - RSS feed generator
# Copyright (c) 2025 Tomasita Cabrera
# Licensed under the MIT License - see LICENSE file for details
"""
Script to generate an RSS feed for the comic
"""

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# Sunday Comics - Sitemap generator
# Copyright (c) 2025 Tomasita Cabrera
# Licensed under the MIT License - see LICENSE file for details
"""
Script to generate a sitemap.xml file for the comic
"""
import sys
import os
from xml.etree.ElementTree import Element, SubElement, tostring
from xml.dom import minidom
# Add parent directory to path so we can import comics_data
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from comics_data import COMICS, SITE_URL
def generate_sitemap():
"""Generate sitemap.xml from COMICS data"""
# Create sitemap root
urlset = Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
# Add homepage
if COMICS:
latest_date = COMICS[-1]['date']
url = SubElement(urlset, 'url')
SubElement(url, 'loc').text = f'{SITE_URL}/'
SubElement(url, 'lastmod').text = latest_date
SubElement(url, 'changefreq').text = 'weekly'
SubElement(url, 'priority').text = '1.0'
# Add archive page
if COMICS:
latest_date = COMICS[-1]['date']
url = SubElement(urlset, 'url')
SubElement(url, 'loc').text = f'{SITE_URL}/archive'
SubElement(url, 'lastmod').text = latest_date
SubElement(url, 'changefreq').text = 'weekly'
SubElement(url, 'priority').text = '0.9'
# Add about page
url = SubElement(urlset, 'url')
SubElement(url, 'loc').text = f'{SITE_URL}/about'
SubElement(url, 'changefreq').text = 'monthly'
SubElement(url, 'priority').text = '0.7'
# Add all individual comic pages
for comic in COMICS:
url = SubElement(urlset, 'url')
SubElement(url, 'loc').text = f"{SITE_URL}/comic/{comic['number']}"
SubElement(url, 'lastmod').text = comic['date']
SubElement(url, 'changefreq').text = 'never'
SubElement(url, 'priority').text = '0.8'
# Convert to pretty XML
xml_str = minidom.parseString(tostring(urlset)).toprettyxml(indent=' ')
# Remove extra blank lines
xml_str = '\n'.join([line for line in xml_str.split('\n') if line.strip()])
return xml_str
def main():
"""Generate and save sitemap"""
# Get path to static folder
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(script_dir)
static_dir = os.path.join(parent_dir, 'static')
# Create static directory if it doesn't exist
os.makedirs(static_dir, exist_ok=True)
# Generate sitemap
sitemap_content = generate_sitemap()
# Save to file
sitemap_file = os.path.join(static_dir, 'sitemap.xml')
with open(sitemap_file, 'w', encoding='utf-8') as f:
f.write(sitemap_content)
print(f"Sitemap generated: {sitemap_file}")
print(f"Total URLs: {len(COMICS) + 3}") # comics + homepage + archive + about
if __name__ == '__main__':
main()

84
scripts/publish_comic.py Normal file
View 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
View 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()

View File

@@ -65,6 +65,70 @@ body {
background-color: var(--color-background);
}
/* Screen reader only content - visually hidden but accessible to assistive technologies */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Skip to main content link - hidden until focused */
.skip-to-main {
position: absolute;
top: -100px;
left: 0;
z-index: 9999;
padding: var(--space-md) var(--space-lg);
background-color: var(--color-primary);
color: var(--color-background);
text-decoration: none;
font-weight: bold;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-tight);
border: var(--border-width-thick) solid var(--color-primary);
}
.skip-to-main:focus {
top: 0;
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* Focus indicators for accessibility */
a:focus,
button:focus,
input:focus,
.btn:focus,
.btn-nav:focus,
.btn-icon-nav:focus {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* Focus visible for modern browsers (shows focus only on keyboard navigation) */
a:focus-visible,
button:focus-visible,
input:focus-visible,
.btn:focus-visible,
.btn-nav:focus-visible,
.btn-icon-nav:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* Ensure disabled buttons don't show interactive focus */
.btn-disabled:focus,
.btn-icon-disabled:focus {
outline: 2px solid var(--color-disabled);
outline-offset: 2px;
}
.container {
max-width: var(--container-max-width);
margin: 0 auto;
@@ -135,6 +199,30 @@ nav .container {
font-weight: bold;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-wide);
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.nav-logo {
height: 2.5rem;
width: auto;
display: inline-block;
vertical-align: middle;
}
.nav-logo-beside {
/* Logo displayed beside the title */
}
.nav-logo-replace {
/* Logo replaces the title - can be larger */
height: 3rem;
}
.nav-title {
/* Title text when displayed beside logo */
display: inline-block;
}
.nav-links {
@@ -326,6 +414,11 @@ main {
background: var(--color-background);
}
/* Remove outline when comic image container is focused programmatically (for keyboard nav) */
.comic-image:focus {
outline: none;
}
.comic-image a {
display: block;
cursor: pointer;
@@ -338,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);
@@ -444,6 +561,25 @@ main {
margin-top: var(--space-md);
}
/* Section Headers for Archive */
.section-header {
margin-top: var(--space-2xl);
margin-bottom: var(--space-lg);
padding-bottom: var(--space-sm);
border-bottom: var(--border-width-thin) solid var(--border-color);
}
.section-header:first-child {
margin-top: 0;
}
.section-header h2 {
color: var(--color-text);
font-size: var(--font-size-xl);
text-transform: uppercase;
letter-spacing: var(--letter-spacing-tight);
}
.archive-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--archive-grid-min), 1fr));
@@ -465,6 +601,14 @@ main {
display: block;
}
/* Enhanced focus indicator for archive items */
.archive-item a:focus,
.archive-item a:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: -3px; /* Inset the outline so it's inside the border */
background: var(--color-hover-bg);
}
.archive-item img {
width: 100%;
height: var(--archive-thumbnail-height);
@@ -528,6 +672,14 @@ main {
font-size: var(--font-size-xl);
}
.nav-logo {
height: 2rem;
}
.nav-logo-replace {
height: 2.5rem;
}
.nav-links {
gap: var(--space-md);
}
@@ -588,6 +740,7 @@ main {
/* Compact footer mobile adjustments */
footer.compact-footer .container {
max-width: none;
flex-direction: column;
align-items: center;
text-align: center;
@@ -616,6 +769,27 @@ main {
footer.compact-footer .footer-bottom::before {
display: none;
}
.footer-divider {
display: none;
}
.footer-bottom {
gap: var(--space-sm);
}
.footer-bottom p,
.footer-terms {
flex-basis: 100%;
text-align: center;
}
.site-credit {
flex-basis: 100%;
display: flex;
justify-content: center;
align-items: center;
}
}
/* Footer */
@@ -717,10 +891,76 @@ footer {
color: var(--color-text-muted);
}
.shareable-banner {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.banner-image {
display: block;
max-width: 100%;
height: auto;
}
.banner-code {
font-size: var(--font-size-sm);
}
.banner-code summary {
cursor: pointer;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: var(--letter-spacing-tight);
padding: var(--space-xs) 0;
}
.banner-code summary:hover {
color: var(--color-text);
}
.banner-code summary:focus {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
.banner-code textarea {
width: 100%;
font-family: var(--font-family);
font-size: var(--font-size-xs);
padding: var(--space-sm);
margin-top: var(--space-xs);
border: var(--border-width-thin) solid var(--border-color);
background-color: var(--color-background);
color: var(--color-text);
resize: vertical;
min-height: 60px;
}
.banner-code textarea:focus {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
.site-credit {
display: flex;
flex-direction: column;
}
.credit-image {
display: block;
max-width: 100%;
height: auto;
}
.footer-bottom {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding-top: var(--space-lg);
border-top: var(--border-width-thin) solid var(--border-color);
flex-wrap: wrap;
}
.footer-bottom p {
@@ -731,6 +971,11 @@ footer {
letter-spacing: var(--letter-spacing-tight);
}
.footer-divider {
color: var(--color-text);
font-size: var(--font-size-md);
}
.footer-bottom a.api-link {
color: var(--color-text);
text-decoration: none;
@@ -743,6 +988,18 @@ footer {
text-decoration: underline;
}
.footer-terms {
color: var(--color-text);
text-decoration: none;
font-size: var(--font-size-md);
transition: opacity 0.2s ease;
}
.footer-terms:hover {
text-decoration: underline;
opacity: 0.8;
}
/* Compact Footer Mode */
footer.compact-footer {
border-top: none;
@@ -751,6 +1008,7 @@ footer.compact-footer {
}
footer.compact-footer .container {
max-width: none;
display: flex;
align-items: center;
justify-content: center;
@@ -834,3 +1092,236 @@ footer.compact-footer .footer-section:not(:last-child)::after {
margin-left: var(--space-md);
color: var(--color-text-muted);
}
/* ============================================================
SHARE/EMBED FEATURE STYLES
============================================================ */
/* Share section (contains permalink and embed buttons) */
.comic-share-section {
margin-top: var(--space-lg);
text-align: center;
display: flex;
justify-content: center;
gap: var(--space-md);
flex-wrap: wrap;
}
/* Permalink button */
.btn-permalink {
padding: var(--space-sm) var(--space-lg);
background-color: var(--color-background);
border: var(--border-width-thin) solid var(--color-border);
color: var(--color-text);
font-family: var(--font-family);
font-size: var(--font-size-md);
cursor: pointer;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-tight);
transition: background-color var(--transition-speed);
}
.btn-permalink:hover {
background-color: var(--color-hover-bg);
}
.btn-permalink:focus {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
.btn-permalink.copied {
background-color: var(--color-primary);
color: var(--color-background);
}
/* Embed button */
.btn-embed {
padding: var(--space-sm) var(--space-lg);
background-color: var(--color-background);
border: var(--border-width-thin) solid var(--color-border);
color: var(--color-text);
font-family: var(--font-family);
font-size: var(--font-size-md);
cursor: pointer;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-tight);
transition: background-color var(--transition-speed);
}
.btn-embed:hover {
background-color: var(--color-hover-bg);
}
.btn-embed:focus {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* Share button icons */
.btn-with-icon {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.btn-with-icon .btn-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.btn-permalink.copied .btn-icon {
filter: invert(1);
}
/* Modal overlay */
.modal {
display: none; /* Hidden by default */
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.6);
}
/* Modal content box */
.modal-content {
background-color: var(--color-background);
margin: 10% auto;
padding: 0;
border: var(--border-width-thick) solid var(--color-border);
max-width: 600px;
width: 90%;
}
/* Modal header */
.modal-header {
padding: var(--space-md) var(--space-lg);
border-bottom: var(--border-width-thin) solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-size: var(--font-size-xl);
text-transform: uppercase;
letter-spacing: var(--letter-spacing-tight);
}
/* Modal close button */
.modal-close {
background: none;
border: none;
font-size: var(--font-size-3xl);
font-weight: bold;
cursor: pointer;
color: var(--color-text);
line-height: 1;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover,
.modal-close:focus {
color: var(--color-text-muted);
outline: none;
}
/* Modal body */
.modal-body {
padding: var(--space-lg);
}
.modal-body p {
margin-bottom: var(--space-md);
font-size: var(--font-size-md);
}
/* Embed code textarea */
#embed-code {
width: 100%;
min-height: 100px;
padding: var(--space-md);
font-family: var(--font-family);
font-size: var(--font-size-sm);
border: var(--border-width-thin) solid var(--color-border);
background-color: var(--color-background);
color: var(--color-text);
resize: vertical;
margin-bottom: var(--space-md);
}
#embed-code:focus {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* Copy button */
.btn-copy {
padding: var(--space-sm) var(--space-lg);
background-color: var(--color-primary);
border: var(--border-width-thin) solid var(--color-border);
color: var(--color-background);
font-family: var(--font-family);
font-size: var(--font-size-md);
cursor: pointer;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-tight);
width: 100%;
transition: background-color var(--transition-speed);
}
.btn-copy:hover {
background-color: var(--color-text);
}
.btn-copy:focus {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
.btn-copy.copied {
background-color: var(--color-text-muted);
}
/* Embed preview link */
.embed-preview-link {
margin-top: var(--space-md);
font-size: var(--font-size-sm);
text-align: center;
}
.embed-preview-link a {
color: var(--color-text);
text-decoration: underline;
}
.embed-preview-link a:hover {
color: var(--color-text-muted);
}
/* Mobile responsive modal */
@media (max-width: 768px) {
.modal-content {
margin: 20% auto;
width: 95%;
}
.modal-header h2 {
font-size: var(--font-size-lg);
}
#embed-code {
font-size: var(--font-size-xs);
}
}

BIN
static/images/banner.jpg LFS Normal file

Binary file not shown.

BIN
static/images/icons/embed.png LFS Normal file

Binary file not shown.

BIN
static/images/icons/link.png LFS Normal file

Binary file not shown.

BIN
static/images/logo.png LFS Normal file

Binary file not shown.

BIN
static/images/sunday.jpg LFS Normal file

Binary file not shown.

View 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);
})();

View File

@@ -4,6 +4,8 @@
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) {
@@ -29,6 +31,13 @@
// Update the page with comic data
function displayComic(comic) {
const title = comic.title || `#${comic.number}`;
currentComicNumber = comic.number;
// Announce comic change to screen readers
const announcer = document.getElementById('comic-announcer');
if (announcer) {
announcer.textContent = `Loaded comic ${title}, dated ${comic.formatted_date || comic.date}`;
}
// Update container class for full-width option
const container = document.querySelector('.comic-container');
@@ -46,7 +55,7 @@
}
// Show/hide header based on plain mode
const header = document.querySelector('.comic-header');
let header = document.querySelector('.comic-header');
if (comic.plain) {
if (header) {
header.style.display = 'none';
@@ -60,21 +69,29 @@
newHeader.className = 'comic-header';
newHeader.innerHTML = '<h1></h1><p class="comic-date"></p>';
container.insertBefore(newHeader, container.firstChild);
header = newHeader; // Update reference to the newly created element
}
// Update title and date
document.querySelector('.comic-header h1').textContent = title;
document.querySelector('.comic-date').textContent = comic.date;
// Update title and date using the header reference
header.querySelector('h1').textContent = title;
header.querySelector('.comic-date').textContent = comic.date;
}
// Update image and its link
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
const transcriptDiv = document.querySelector('.comic-transcript');
let transcriptDiv = document.querySelector('.comic-transcript');
if (comic.author_note) {
if (!transcriptDiv) {
const container = document.querySelector('.comic-container');
@@ -82,6 +99,7 @@
newDiv.className = 'comic-transcript';
newDiv.innerHTML = '<h3>Author Note</h3>';
container.appendChild(newDiv);
transcriptDiv = newDiv; // Update reference to the newly created element
}
// Clear existing content after the h3
@@ -114,6 +132,48 @@
// Update URL without reload
history.pushState({ comicId: comic.number }, '', `/comic/${comic.number}`);
// Dispatch custom event for other features (like embed button)
window.dispatchEvent(new CustomEvent('comicUpdated', {
detail: { comicNumber: comic.number }
}));
// Move focus to comic image for keyboard navigation accessibility
const comicImageFocus = document.getElementById('comic-image-focus');
if (comicImageFocus) {
comicImageFocus.focus();
}
}
// 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
@@ -121,30 +181,60 @@
// 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);
}
}
}
@@ -163,6 +253,7 @@
if (currentNumber < totalComics) {
const link = document.createElement('a');
link.href = `/comic/${currentNumber + 1}`;
link.setAttribute('aria-label', 'Click to view next comic');
link.onclick = (e) => {
e.preventDefault();
loadComic(currentNumber + 1);
@@ -184,9 +275,23 @@
if (currentNumber > 1) {
firstBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
firstBtn.onclick = (e) => { e.preventDefault(); loadComic(1); };
firstBtn.removeAttribute('aria-disabled');
if (firstBtn.tagName === 'SPAN') {
firstBtn.setAttribute('tabindex', '0');
firstBtn.setAttribute('role', 'button');
firstBtn.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
loadComic(1);
}
};
}
} else {
firstBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
firstBtn.onclick = null;
firstBtn.onkeydown = null;
firstBtn.setAttribute('aria-disabled', 'true');
firstBtn.removeAttribute('tabindex');
}
// Previous button
@@ -194,9 +299,23 @@
if (currentNumber > 1) {
prevBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
prevBtn.onclick = (e) => { e.preventDefault(); loadComic(currentNumber - 1); };
prevBtn.removeAttribute('aria-disabled');
if (prevBtn.tagName === 'SPAN') {
prevBtn.setAttribute('tabindex', '0');
prevBtn.setAttribute('role', 'button');
prevBtn.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
loadComic(currentNumber - 1);
}
};
}
} else {
prevBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
prevBtn.onclick = null;
prevBtn.onkeydown = null;
prevBtn.setAttribute('aria-disabled', 'true');
prevBtn.removeAttribute('tabindex');
}
// Comic date display
@@ -209,9 +328,23 @@
if (currentNumber < totalComics) {
nextBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
nextBtn.onclick = (e) => { e.preventDefault(); loadComic(currentNumber + 1); };
nextBtn.removeAttribute('aria-disabled');
if (nextBtn.tagName === 'SPAN') {
nextBtn.setAttribute('tabindex', '0');
nextBtn.setAttribute('role', 'button');
nextBtn.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
loadComic(currentNumber + 1);
}
};
}
} else {
nextBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
nextBtn.onclick = null;
nextBtn.onkeydown = null;
nextBtn.setAttribute('aria-disabled', 'true');
nextBtn.removeAttribute('tabindex');
}
// Latest button
@@ -219,9 +352,72 @@
if (currentNumber < totalComics) {
latestBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
latestBtn.onclick = (e) => { e.preventDefault(); loadComic(totalComics); };
latestBtn.removeAttribute('aria-disabled');
if (latestBtn.tagName === 'SPAN') {
latestBtn.setAttribute('tabindex', '0');
latestBtn.setAttribute('role', 'button');
latestBtn.onkeydown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
loadComic(totalComics);
}
};
}
} else {
latestBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
latestBtn.onclick = null;
latestBtn.onkeydown = null;
latestBtn.setAttribute('aria-disabled', 'true');
latestBtn.removeAttribute('tabindex');
}
}
// Handle keyboard navigation
function handleKeyboardNavigation(event) {
// Don't interfere if user is typing in an input field
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
return;
}
const announcer = document.getElementById('comic-announcer');
switch(event.key) {
case 'ArrowLeft':
// Previous comic
if (currentComicNumber > 1) {
event.preventDefault();
loadComic(currentComicNumber - 1);
} else if (announcer) {
announcer.textContent = 'Already at the first comic';
}
break;
case 'ArrowRight':
// Next comic
if (currentComicNumber < totalComics) {
event.preventDefault();
loadComic(currentComicNumber + 1);
} else if (announcer) {
announcer.textContent = 'Already at the latest comic';
}
break;
case 'Home':
// First comic
if (currentComicNumber > 1) {
event.preventDefault();
loadComic(1);
} else if (announcer) {
announcer.textContent = 'Already at the first comic';
}
break;
case 'End':
// Latest comic
if (currentComicNumber < totalComics) {
event.preventDefault();
loadComic(totalComics);
} else if (announcer) {
announcer.textContent = 'Already at the latest comic';
}
break;
}
}
@@ -243,13 +439,24 @@
// Get current comic number
const currentNumber = parseInt(document.querySelector('.comic-container').dataset.comicNumber || 0);
currentComicNumber = currentNumber;
if (currentNumber && totalComics) {
// Get the formatted date from the DOM (already rendered by server)
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
@@ -259,6 +466,9 @@
}
});
// Handle keyboard navigation
document.addEventListener('keydown', handleKeyboardNavigation);
// Set initial state
history.replaceState({ comicId: currentNumber }, '', window.location.pathname);
}

129
static/js/embed.js Normal file
View File

@@ -0,0 +1,129 @@
// Embed functionality for Sunday Comics
// Handles showing embed code modal and copying to clipboard
(function() {
'use strict';
const modal = document.getElementById('embed-modal');
const embedButton = document.getElementById('embed-button');
const closeButton = modal ? modal.querySelector('.modal-close') : null;
const embedCodeTextarea = document.getElementById('embed-code');
const copyButton = document.getElementById('copy-embed-code');
const previewLink = document.getElementById('embed-preview-link');
if (!modal || !embedButton) {
// Embed feature not enabled or elements not found
return;
}
// Get the site URL from the page (we'll add it as a data attribute)
const siteUrl = document.body.getAttribute('data-site-url') || window.location.origin;
// Open modal when embed button is clicked
embedButton.addEventListener('click', function() {
const comicNumber = this.getAttribute('data-comic-number');
if (!comicNumber) return;
// Generate embed code
const embedUrl = `${siteUrl}/embed/${comicNumber}`;
const embedCode = `<iframe src="${embedUrl}" width="800" height="600" frameborder="0" scrolling="no" style="max-width: 100%;" title="Comic #${comicNumber}"></iframe>`;
// Set the embed code in the textarea
embedCodeTextarea.value = embedCode;
// Set the preview link
previewLink.href = embedUrl;
// Show the modal
modal.style.display = 'block';
modal.setAttribute('aria-hidden', 'false');
// Focus on the textarea
embedCodeTextarea.focus();
embedCodeTextarea.select();
});
// Close modal when close button is clicked
if (closeButton) {
closeButton.addEventListener('click', function() {
closeModal();
});
}
// Close modal when clicking outside the modal content
modal.addEventListener('click', function(event) {
if (event.target === modal) {
closeModal();
}
});
// Close modal with Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && modal.style.display === 'block') {
closeModal();
}
});
// Copy embed code to clipboard
if (copyButton) {
copyButton.addEventListener('click', function() {
embedCodeTextarea.select();
embedCodeTextarea.setSelectionRange(0, 99999); // For mobile devices
try {
// Modern clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(embedCodeTextarea.value).then(function() {
showCopyFeedback();
}).catch(function() {
// Fallback to execCommand
fallbackCopy();
});
} else {
// Fallback for older browsers
fallbackCopy();
}
} catch (err) {
fallbackCopy();
}
});
}
function fallbackCopy() {
try {
document.execCommand('copy');
showCopyFeedback();
} catch (err) {
alert('Failed to copy. Please select and copy manually.');
}
}
function showCopyFeedback() {
const originalText = copyButton.textContent;
copyButton.textContent = 'Copied!';
copyButton.classList.add('copied');
setTimeout(function() {
copyButton.textContent = originalText;
copyButton.classList.remove('copied');
}, 2000);
}
function closeModal() {
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
// Return focus to the embed button
if (embedButton) {
embedButton.focus();
}
}
// Update embed button when comic changes via client-side navigation
// This integrates with the existing comic-nav.js functionality
window.addEventListener('comicUpdated', function(event) {
if (event.detail && event.detail.comicNumber && embedButton) {
embedButton.setAttribute('data-comic-number', event.detail.comicNumber);
}
});
})();

84
static/js/permalink.js Normal file
View File

@@ -0,0 +1,84 @@
// Permalink functionality for Sunday Comics
// Handles copying comic permalinks to clipboard
(function() {
'use strict';
const permalinkButton = document.getElementById('permalink-button');
if (!permalinkButton) {
// Permalink feature not enabled or button not found
return;
}
// Get the site URL from the page
const siteUrl = document.body.getAttribute('data-site-url') || window.location.origin;
// Copy permalink when button is clicked
permalinkButton.addEventListener('click', function() {
const comicNumber = this.getAttribute('data-comic-number');
if (!comicNumber) return;
// Generate permalink URL
const permalink = `${siteUrl}/comic/${comicNumber}`;
// Copy to clipboard
copyToClipboard(permalink);
});
// Copy text to clipboard
function copyToClipboard(text) {
// Modern clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
showCopyFeedback();
}).catch(function() {
// Fallback for older browsers
fallbackCopy(text);
});
} else {
// Fallback for older browsers
fallbackCopy(text);
}
}
// Fallback copy method for older browsers
function fallbackCopy(text) {
// Create temporary textarea
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand('copy');
showCopyFeedback();
} catch (err) {
alert('Failed to copy. Please copy manually: ' + text);
}
document.body.removeChild(textarea);
}
// Show visual feedback that the permalink was copied
function showCopyFeedback() {
const originalText = permalinkButton.textContent;
permalinkButton.textContent = 'Copied!';
permalinkButton.classList.add('copied');
setTimeout(function() {
permalinkButton.textContent = originalText;
permalinkButton.classList.remove('copied');
}, 2000);
}
// Update permalink button when comic changes via client-side navigation
window.addEventListener('comicUpdated', function(event) {
if (event.detail && event.detail.comicNumber && permalinkButton) {
permalinkButton.setAttribute('data-comic-number', event.detail.comicNumber);
}
});
})();

View File

@@ -16,39 +16,107 @@ 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
items:
$ref: '#/components/schemas/Comic'
example:
- number: 1
title: "First Comic"
filename: "comic-001.jpg"
mobile_filename: "comic-001-mobile.jpg"
date: "2025-01-01"
alt_text: "The very first comic"
author_note: "This is where your comic journey begins!"
full_width: true
plain: true
formatted_date: "Wednesday, January 1, 2025"
author_note_is_html: false
- 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
oneOf:
- type: array
description: Simple array response (when no pagination parameters provided)
items:
$ref: '#/components/schemas/Comic'
- $ref: '#/components/schemas/PaginatedComicsResponse'
examples:
simpleArray:
summary: Simple array response (default)
value:
- number: 1
title: "First Comic"
filename: "comic-001.jpg"
mobile_filename: "comic-001-mobile.jpg"
date: "2025-01-01"
alt_text: "The very first comic"
author_note: "This is where your comic journey begins!"
full_width: true
plain: true
formatted_date: "Wednesday, January 1, 2025"
author_note_is_html: false
- 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
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:
type: string
description: Image filename in static/images/comics/
oneOf:
- type: string
description: Single image filename in static/images/comics/
- type: array
description: Multiple image filenames for webtoon-style comics
items:
type: string
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:
type: string
description: Accessibility text for the comic image
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
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:

View File

@@ -7,30 +7,45 @@
<div class="page-header{% if archive_full_width %} page-header-fullwidth{% endif %}">
<h1>Comic Archive</h1>
<p>Browse all {{ comics|length }} comics</p>
<p>Browse all {{ total_comics }} comics</p>
</div>
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}">
<div class="archive-grid{% if archive_full_width %} archive-grid-fullwidth{% endif %}">
{% for comic in comics %}
<div class="archive-item{% if archive_full_width %} archive-item-fullwidth{% endif %}">
<a href="{{ url_for('comic', comic_id=comic.number) }}">
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}">
<div class="archive-info">
{% if not archive_full_width %}
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
{% endif %}
<p class="archive-date">{{ comic.date }}</p>
</div>
</a>
<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">
<h2>{{ section_title }}</h2>
</div>
{% endfor %}
</div>
{% endif %}
<div class="archive-grid{% if archive_full_width %} archive-grid-fullwidth{% endif %}">
{% for comic in section_comics %}
<div class="archive-item{% if archive_full_width %} archive-item-fullwidth{% endif %}">
<a href="{{ url_for('comic', comic_id=comic.number) }}">
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
loading="lazy">
<div class="archive-info">
{% if not archive_full_width %}
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
{% endif %}
<p class="archive-date">{{ comic.date }}</p>
</div>
</a>
</div>
{% endfor %}
</div>
{% endfor %}
</section>
{% if archive_full_width %}
<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 %}

View File

@@ -7,6 +7,14 @@
<!-- SEO Meta Tags -->
<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">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
@@ -32,7 +40,10 @@
<link rel="alternate" type="application/rss+xml" title="{{ comic_name }} RSS Feed" href="{{ url_for('static', filename='feed.rss') }}">
{% block extra_css %}{% endblock %}
</head>
<body>
<body data-site-url="{{ site_url }}">
<!-- Skip to main content link for keyboard navigation -->
<a href="#main-content" class="skip-to-main">Skip to main content</a>
{% if header_image %}
<div class="site-header-image">
<img src="{{ url_for('static', filename='images/' + header_image) }}" alt="{{ comic_name }} Header">
@@ -44,23 +55,32 @@
<div class="container">
{% if not header_image %}
<div class="nav-brand">
<a href="{{ url_for('index') }}">{{ comic_name }}</a>
<a href="{{ url_for('index') }}">
{% if logo_image and logo_mode == 'beside' %}
<img src="{{ url_for('static', filename='images/' + logo_image) }}" alt="{{ comic_name }} Logo" class="nav-logo nav-logo-beside">
<span class="nav-title">{{ comic_name }}</span>
{% elif logo_image and logo_mode == 'replace' %}
<img src="{{ url_for('static', filename='images/' + logo_image) }}" alt="{{ comic_name }}" class="nav-logo nav-logo-replace">
{% else %}
{{ comic_name }}
{% endif %}
</a>
</div>
{% endif %}
<ul class="nav-links">
<li>
<a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}>
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/alert.png') }}" alt="" class="nav-icon">{% endif %}Latest
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/alert.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}Latest
</a>
</li>
<li>
<a href="{{ url_for('archive') }}" {% if request.endpoint == 'archive' %}class="active"{% endif %}>
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/archive.png') }}" alt="" class="nav-icon">{% endif %}Archive
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/archive.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}Archive
</a>
</li>
<li>
<a href="{{ url_for('about') }}" {% if request.endpoint == 'about' %}class="active"{% endif %}>
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/info.png') }}" alt="" class="nav-icon">{% endif %}About
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/info.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}About
</a>
</li>
</ul>
@@ -68,7 +88,7 @@
</nav>
</header>
<main>
<main id="main-content">
<div class="container">
{% block content %}{% endblock %}
</div>
@@ -83,7 +103,7 @@
{% if social_instagram %}
<a href="{{ social_instagram }}" target="_blank" rel="noopener noreferrer" aria-label="Instagram">
{% if use_footer_social_icons %}
<img src="{{ url_for('static', filename='images/icons/instagram.png') }}" alt="Instagram" class="social-icon">
<img src="{{ url_for('static', filename='images/icons/instagram.png') }}" alt="" class="social-icon">
{% else %}
Instagram
{% endif %}
@@ -92,7 +112,7 @@
{% if social_youtube %}
<a href="{{ social_youtube }}" target="_blank" rel="noopener noreferrer" aria-label="YouTube">
{% if use_footer_social_icons %}
<img src="{{ url_for('static', filename='images/icons/youtube.png') }}" alt="YouTube" class="social-icon">
<img src="{{ url_for('static', filename='images/icons/youtube.png') }}" alt="" class="social-icon">
{% else %}
YouTube
{% endif %}
@@ -101,7 +121,7 @@
{% if social_email %}
<a href="{{ social_email }}" aria-label="Email">
{% if use_footer_social_icons %}
<img src="{{ url_for('static', filename='images/icons/mail .png') }}" alt="Email" class="social-icon">
<img src="{{ url_for('static', filename='images/icons/mail .png') }}" alt="" class="social-icon">
{% else %}
Email
{% endif %}
@@ -109,7 +129,7 @@
{% endif %}
<a href="{{ url_for('static', filename='feed.rss') }}" aria-label="RSS Feed">
{% if use_footer_social_icons %}
<img src="{{ url_for('static', filename='images/icons/rss.png') }}" alt="RSS" class="social-icon">
<img src="{{ url_for('static', filename='images/icons/rss.png') }}" alt="" class="social-icon">
{% else %}
RSS Feed
{% endif %}
@@ -120,6 +140,7 @@
</div>
</div>
{% if newsletter_enabled %}
<div class="footer-section">
<h3>Newsletter</h3>
<!-- Replace with your newsletter service form -->
@@ -129,10 +150,34 @@
</form> -->
<p class="newsletter-placeholder">Newsletter coming soon!</p>
</div>
{% endif %}
{% if banner_image and not compact_footer %}
<div class="footer-section">
<h3>Share A Link</h3>
<div class="shareable-banner">
<a href="{{ site_url }}" target="_blank" rel="noopener noreferrer" aria-label="Link to {{ comic_name }} home page">
<img src="{{ url_for('static', filename='images/' + banner_image) }}" alt="{{ comic_name }}" class="banner-image">
</a>
<details class="banner-code">
<summary>Get code</summary>
<textarea readonly onfocus="this.select()" onclick="this.select()" aria-label="HTML code for linking to {{ comic_name }}"><a href="{{ site_url }}"><img src="{{ site_url }}/static/images/{{ banner_image }}" alt="{{ comic_name }}"></a></textarea>
</details>
</div>
</div>
{% endif %}
</div>
<div class="footer-bottom">
<p>&copy; 2025 {{ comic_name }}. All rights reserved.</p>
<p>&copy; {{ current_year }} {{ copyright_name }}. All rights reserved.</p>
<span class="footer-divider" aria-hidden="true">|</span>
<a href="{{ url_for('terms') }}" class="footer-terms">Terms of Service</a>
<span class="footer-divider" aria-hidden="true">|</span>
<div class="site-credit">
<a href="https://git.puercito.net/mi/sunday" target="_blank" rel="noopener noreferrer" aria-label="Sunday Comics - Webcomic platform">
<img src="{{ url_for('static', filename='images/sunday.jpg') }}" alt="Sunday Comics" class="credit-image">
</a>
</div>
</div>
</div>
</footer>
@@ -143,7 +188,31 @@
</div>
{% endif %}
<!-- Embed Code Modal -->
{% if embed_enabled %}
<div id="embed-modal" class="modal" role="dialog" aria-labelledby="embed-modal-title" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<h2 id="embed-modal-title">Embed This Comic</h2>
<button class="modal-close" aria-label="Close embed modal">&times;</button>
</div>
<div class="modal-body">
<p>Copy this code to embed the comic on your website:</p>
<textarea id="embed-code" readonly onfocus="this.select()" onclick="this.select()" aria-label="Embed code"></textarea>
<button id="copy-embed-code" class="btn-copy" aria-label="Copy embed code to clipboard">Copy Code</button>
<p class="embed-preview-link">Preview: <a id="embed-preview-link" href="#" target="_blank" rel="noopener noreferrer">Open embed in new window</a></p>
</div>
</div>
</div>
{% endif %}
<script src="{{ url_for('static', filename='js/comic-nav.js') }}"></script>
{% if embed_enabled %}
<script src="{{ url_for('static', filename='js/embed.js') }}"></script>
{% endif %}
{% if permalink_enabled %}
<script src="{{ url_for('static', filename='js/permalink.js') }}"></script>
{% endif %}
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -4,7 +4,31 @@
{% block og_image %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endblock %}
{% block extra_css %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ComicStory",
"name": "{{ comic.title if comic.title else '#' ~ comic.number }}",
"datePublished": "{{ comic.date }}",
"image": "{{ site_url }}/static/images/comics/{{ comic.filename }}",
"thumbnailUrl": "{{ site_url }}/static/images/thumbs/{{ comic.filename }}",
"description": "{{ comic.alt_text }}",
"isPartOf": {
"@type": "ComicSeries",
"name": "{{ comic_name }}",
"url": "{{ site_url }}"
},
"position": "{{ comic.number }}",
"url": "{{ site_url }}/comic/{{ comic.number }}"
}
</script>
{% endblock %}
{% block content %}
<!-- ARIA live region for screen reader announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only" id="comic-announcer"></div>
<div class="comic-container{% if comic.full_width %} comic-container-fullwidth{% endif %}{% if comic.plain %} comic-container-plain{% endif %}" data-comic-number="{{ comic.number }}" data-total-comics="{{ total_comics }}">
{% if not comic.plain %}
<div class="comic-header">
@@ -13,34 +37,48 @@
</div>
{% endif %}
<div class="comic-image">
{% if comic.number < total_comics %}
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}">
{% if comic.mobile_filename %}
<picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
{% endif %}
</a>
<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) }}">
{# 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 %}
<picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
title="{{ comic.alt_text }}"
loading="eager">
{% endif %}
</a>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
{% if comic.mobile_filename %}
<picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}"
loading="eager">
{% endif %}
{% endif %}
{% endif %}
</div>
@@ -51,17 +89,17 @@
{# Icon-based navigation #}
{% if comic.number > 1 %}
<a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="First">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
</a>
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="Previous">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
</a>
{% else %}
<span class="btn-icon-nav btn-icon-disabled" aria-label="First">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="First">
<span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
</span>
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="Previous">
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
</span>
{% endif %}
@@ -69,17 +107,17 @@
{% if comic.number < total_comics %}
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="Next">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
</a>
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="Latest">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
</a>
{% else %}
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="Next">
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
</span>
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="Latest">
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
</span>
{% endif %}
{% else %}
@@ -88,8 +126,8 @@
<a href="{{ url_for('comic', comic_id=1) }}" class="btn btn-nav">First</a>
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn btn-nav">Previous</a>
{% else %}
<span class="btn btn-nav btn-disabled">First</span>
<span class="btn btn-nav btn-disabled">Previous</span>
<span class="btn btn-nav btn-disabled" aria-disabled="true">First</span>
<span class="btn btn-nav btn-disabled" aria-disabled="true">Previous</span>
{% endif %}
<span class="comic-date-display">{{ comic.formatted_date }}</span>
@@ -98,13 +136,28 @@
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn btn-nav">Next</a>
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn btn-nav">Latest</a>
{% else %}
<span class="btn btn-nav btn-disabled">Next</span>
<span class="btn btn-nav btn-disabled">Latest</span>
<span class="btn btn-nav btn-disabled" aria-disabled="true">Next</span>
<span class="btn btn-nav btn-disabled" aria-disabled="true">Latest</span>
{% endif %}
{% endif %}
</div>
</div>
{% if embed_enabled or permalink_enabled %}
<div class="comic-share-section">
{% if permalink_enabled %}
<button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic">
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/link.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink
</button>
{% endif %}
{% if embed_enabled %}
<button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic">
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/embed.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed
</button>
{% endif %}
</div>
{% endif %}
{% if comic.author_note %}
<div class="comic-transcript">
<h3>Author Note</h3>

156
templates/embed.html Normal file
View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} - {{ comic_name }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', Courier, monospace;
background: #fff;
color: #000;
line-height: 1.5;
margin: 0;
padding: 0;
}
.embed-container {
max-width: 100%;
margin: 0;
padding: 0;
text-align: center;
border: 2px solid #000;
background: #fff;
}
.embed-header {
padding: 15px;
border-bottom: 2px solid #000;
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
}
.embed-header-text {
flex: 1;
text-align: left;
}
.embed-logo {
max-width: 120px;
max-height: 60px;
height: auto;
flex-shrink: 0;
}
.embed-title {
font-size: 1.1em;
font-weight: bold;
margin: 0 0 5px 0;
color: #000;
text-transform: uppercase;
letter-spacing: 1px;
}
.embed-date {
font-size: 0.75em;
color: #666;
margin: 0;
}
.embed-image-wrapper {
padding: 0;
background: #fff;
border-bottom: 2px solid #000;
}
.embed-image-link {
display: block;
margin: 0;
line-height: 0;
}
.embed-image {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
border: none;
}
.embed-footer {
padding: 12px 15px;
font-size: 0.75em;
background: #f8f8f8;
border-top: 1px solid #ddd;
}
.embed-footer a {
color: #000;
text-decoration: none;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
}
.embed-footer a:hover {
text-decoration: underline;
}
.embed-alt-text {
display: none;
}
/* Mobile responsive */
@media (max-width: 480px) {
.embed-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.embed-logo {
max-width: 100px;
max-height: 50px;
align-self: flex-end;
}
.embed-title {
font-size: 0.9em;
}
.embed-date {
font-size: 0.7em;
}
.embed-footer {
font-size: 0.7em;
padding: 10px;
}
}
</style>
</head>
<body>
<div class="embed-container">
<div class="embed-header">
<div class="embed-header-text">
<h1 class="embed-title">{{ comic.title if comic.title else '#' ~ comic.number }}</h1>
<p class="embed-date">{{ comic.formatted_date }}</p>
</div>
{% if logo_image %}
<img src="{{ site_url }}/static/images/{{ logo_image }}" alt="{{ comic_name }}" class="embed-logo">
{% endif %}
</div>
<div class="embed-image-wrapper">
<a href="{{ site_url }}/comic/{{ comic.number }}" class="embed-image-link" target="_blank" rel="noopener noreferrer" aria-label="View {{ comic.title if comic.title else 'comic #' ~ comic.number }} on {{ comic_name }}">
{% if comic.mobile_filename %}
<picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}"
class="embed-image">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}"
class="embed-image">
{% endif %}
</a>
</div>
<div class="embed-footer">
<a href="{{ site_url }}/comic/{{ comic.number }}" target="_blank" rel="noopener noreferrer">View on {{ comic_name }} &rarr;</a>
</div>
</div>
</body>
</html>

View File

@@ -7,24 +7,38 @@
{% endif %}
{% block content %}
<!-- ARIA live region for screen reader announcements -->
<div aria-live="polite" aria-atomic="true" class="sr-only" id="comic-announcer"></div>
<div class="comic-container" data-comic-number="{{ comic.number }}" data-total-comics="{{ total_comics }}">
<div class="comic-header">
<h1>{{ comic.title if comic.title else '#' ~ comic.number }}</h1>
<p class="comic-date">{{ comic.date }}</p>
</div>
<div class="comic-image">
{% if comic.mobile_filename %}
<picture>
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
<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) }}">
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
</picture>
{% else %}
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
title="{{ comic.alt_text }}">
{% endif %}
{% endif %}
</div>
@@ -34,17 +48,17 @@
{# Icon-based navigation #}
{% if comic.number > 1 %}
<a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="First">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
</a>
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="Previous">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
</a>
{% else %}
<span class="btn-icon-nav btn-icon-disabled" aria-label="First">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="First">
<span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
</span>
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="Previous">
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
</span>
{% endif %}
@@ -52,17 +66,17 @@
{% if comic.number < total_comics %}
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="Next">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
</a>
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="Latest">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
</a>
{% else %}
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="Next">
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
</span>
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="Latest">
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true">
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
</span>
{% endif %}
{% else %}
@@ -71,8 +85,8 @@
<a href="{{ url_for('comic', comic_id=1) }}" class="btn btn-nav">First</a>
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn btn-nav">Previous</a>
{% else %}
<span class="btn btn-nav btn-disabled">First</span>
<span class="btn btn-nav btn-disabled">Previous</span>
<span class="btn btn-nav btn-disabled" aria-disabled="true">First</span>
<span class="btn btn-nav btn-disabled" aria-disabled="true">Previous</span>
{% endif %}
<span class="comic-date-display">{{ comic.formatted_date }}</span>
@@ -81,13 +95,28 @@
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn btn-nav">Next</a>
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn btn-nav">Latest</a>
{% else %}
<span class="btn btn-nav btn-disabled">Next</span>
<span class="btn btn-nav btn-disabled">Latest</span>
<span class="btn btn-nav btn-disabled" aria-disabled="true">Next</span>
<span class="btn btn-nav btn-disabled" aria-disabled="true">Latest</span>
{% endif %}
{% endif %}
</div>
</div>
{% if embed_enabled or permalink_enabled %}
<div class="comic-share-section">
{% if permalink_enabled %}
<button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic">
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/link.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink
</button>
{% endif %}
{% if embed_enabled %}
<button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic">
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/embed.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed
</button>
{% endif %}
</div>
{% endif %}
{% if comic.author_note %}
<div class="comic-transcript">
<h3>Author Note</h3>

5
version.py Normal file
View 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"