Compare commits
38 Commits
f7c32ca749
...
v2025.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 631bca7923 | |||
| 6dad2194a5 | |||
| 3153455355 | |||
| 52b80563ba | |||
| 61aa0aaba7 | |||
| bbd8e0a96d | |||
| 13176c68d2 | |||
| 91b6d4efeb | |||
| f71720c156 | |||
| 511c9bee48 | |||
| 866bfe4d6d | |||
| 742ff0e553 | |||
| 4ec1feb2a9 | |||
| 418ba6e4ba | |||
| 14415dfcd2 | |||
| 1dac042d25 | |||
| 0eccc4b12e | |||
| b23f2399c4 | |||
| fcb38e593c | |||
| b8abcd5566 | |||
| 8c6481c48c | |||
| 83df9d71ac | |||
| 4cc7485d4f | |||
| def4157b7c | |||
| f07bbc4e1b | |||
| e9c4423779 | |||
| 2a48f00c16 | |||
| 886ac55180 | |||
| 0ce4557df0 | |||
| 894a609329 | |||
| 4a501757ed | |||
| a0b9bb8bfb | |||
| 660e4a516f | |||
| 04fa2073a6 | |||
| f31383800e | |||
| 7e41401ca3 | |||
| ff8bdf9a31 | |||
| 733c8f2d32 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
|||||||
|
|
||||||
# This should be generated on deploy
|
# This should be generated on deploy
|
||||||
static/feed.rss
|
static/feed.rss
|
||||||
|
static/sitemap.xml
|
||||||
|
|
||||||
|
# Comic data cache
|
||||||
|
data/comics/.comics_cache.pkl
|
||||||
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project uses date-based versioning (YYYY.MM.DD).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
### Changed
|
||||||
|
### Deprecated
|
||||||
|
### Removed
|
||||||
|
### Fixed
|
||||||
|
### Security
|
||||||
|
|
||||||
|
## [2025.11.15] - 2025-11-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial version tracking system
|
||||||
|
- `version.py` module for source code version reference
|
||||||
|
- `VERSION` file at project root for easy access
|
||||||
|
- HTML meta tag (`generator`) displaying version in page source
|
||||||
|
- Version number injected into all template contexts
|
||||||
|
- This CHANGELOG.md file to track version history
|
||||||
|
- Version bump script (`scripts/bump_version.py`) to automate releases
|
||||||
|
|
||||||
|
[Unreleased]: https://git.puercito.net/mi/sunday/compare/v2025.11.15...HEAD
|
||||||
|
[2025.11.15]: https://git.puercito.net/mi/sunday/releases/tag/v2025.11.15
|
||||||
330
CLAUDE.md
Normal file
330
CLAUDE.md
Normal 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
|
||||||
836
README.md
836
README.md
@@ -1,7 +1,67 @@
|
|||||||
# Sunday Comics - Webcomic Flask App
|
# Sunday - Webcomic Website Template
|
||||||
|
|
||||||
A Flask-based webcomic website with server-side rendering using Jinja2 templates.
|
A Flask-based webcomic website with server-side rendering using Jinja2 templates.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [What is This?](#what-is-this)
|
||||||
|
- [How Does This Compare to Rarebit?](#how-does-this-compare-to-rarebit)
|
||||||
|
- [Simple Hosting Options](#simple-hosting-options)
|
||||||
|
- [PythonAnywhere (Recommended for Beginners)](#pythonanywhere-recommended-for-beginners)
|
||||||
|
- [Render](#render)
|
||||||
|
- [Railway](#railway)
|
||||||
|
- [DigitalOcean App Platform](#digitalocean-app-platform)
|
||||||
|
- [What You'll Need](#what-youll-need)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Accessibility](#accessibility)
|
||||||
|
- [Accessibility Features](#accessibility-features)
|
||||||
|
- [Testing Accessibility](#testing-accessibility)
|
||||||
|
- [Accessibility Best Practices for Comic Creators](#accessibility-best-practices-for-comic-creators)
|
||||||
|
- [Accessibility Score](#accessibility-score)
|
||||||
|
- [Search Engine Optimization (SEO)](#search-engine-optimization-seo)
|
||||||
|
- [SEO Features](#seo-features)
|
||||||
|
- [Using SEO Features](#using-seo-features)
|
||||||
|
- [SEO Best Practices for Webcomics](#seo-best-practices-for-webcomics)
|
||||||
|
- [SEO Checklist for Launch](#seo-checklist-for-launch)
|
||||||
|
- [Common SEO Questions](#common-seo-questions)
|
||||||
|
- [Content Protection & AI Scraping Prevention](#content-protection--ai-scraping-prevention)
|
||||||
|
- [Protection Features](#protection-features)
|
||||||
|
- [Advanced: Image-Level Protection Tools](#advanced-image-level-protection-tools)
|
||||||
|
- [Important Limitations](#important-limitations)
|
||||||
|
- [Customizing Your Terms](#customizing-your-terms)
|
||||||
|
- [Testing Your Protection](#testing-your-protection)
|
||||||
|
- [Reporting Violations](#reporting-violations)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Setup](#setup)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Global Settings](#global-settings)
|
||||||
|
- [Adding Comics](#adding-comics)
|
||||||
|
- [Adding a New Comic](#adding-a-new-comic)
|
||||||
|
- [Generating RSS Feed](#generating-rss-feed)
|
||||||
|
- [Markdown Support](#markdown-support)
|
||||||
|
- [Production Deployment](#production-deployment)
|
||||||
|
- [Option 1: Docker (Recommended)](#option-1-docker-recommended)
|
||||||
|
- [Option 2: Manual Deployment with Gunicorn](#option-2-manual-deployment-with-gunicorn)
|
||||||
|
- [Using a Reverse Proxy (Recommended)](#using-a-reverse-proxy-recommended)
|
||||||
|
- [Additional Production Considerations](#additional-production-considerations)
|
||||||
|
- [Upgrading to a Database](#upgrading-to-a-database)
|
||||||
|
- [Customization](#customization)
|
||||||
|
- [Branding](#branding)
|
||||||
|
- [Styling](#styling)
|
||||||
|
- [About Page](#about-page)
|
||||||
|
- [Icon Navigation](#icon-navigation)
|
||||||
|
- [Social Links](#social-links)
|
||||||
|
- [Shareable Banner](#shareable-banner)
|
||||||
|
- [Navigation](#navigation)
|
||||||
|
- [Mouse/Touch Navigation](#mousetouch-navigation)
|
||||||
|
- [Keyboard Navigation](#keyboard-navigation)
|
||||||
|
- [Pages](#pages)
|
||||||
|
- [API Endpoints](#api-endpoints)
|
||||||
|
- [Credits](#credits)
|
||||||
|
- [AI Usage Transparency](#ai-usage-transparency)
|
||||||
|
- [License & Content Ownership](#license--content-ownership)
|
||||||
|
|
||||||
## What is This?
|
## What is This?
|
||||||
|
|
||||||
**Sunday Comics** is a simple, ready-to-use website for publishing your webcomic online. If you're an artist or comic creator who wants to share your work on the web without dealing with complex platforms or databases, this is for you.
|
**Sunday Comics** is a simple, ready-to-use website for publishing your webcomic online. If you're an artist or comic creator who wants to share your work on the web without dealing with complex platforms or databases, this is for you.
|
||||||
@@ -12,7 +72,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
|
|||||||
- An archive page where readers can browse all your comics
|
- An archive page where readers can browse all your comics
|
||||||
- RSS feed so readers can subscribe to updates
|
- RSS feed so readers can subscribe to updates
|
||||||
- Mobile-friendly design that works on phones and tablets
|
- Mobile-friendly design that works on phones and tablets
|
||||||
- No database required - just upload images and edit a simple text file
|
- No database required - just upload images and edit simple YAML files
|
||||||
|
|
||||||
**Perfect for:**
|
**Perfect for:**
|
||||||
- Independent comic artists starting their first webcomic
|
- Independent comic artists starting their first webcomic
|
||||||
@@ -21,7 +81,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
|
|||||||
- Anyone looking for a lightweight, customizable comic platform
|
- Anyone looking for a lightweight, customizable comic platform
|
||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
You add your comics by uploading image files and adding basic information (title, date, description) to a configuration file. The website handles everything else - displaying comics with navigation, creating an archive, generating an RSS feed, and making your comics shareable on social media.
|
You add your comics by uploading image files and creating individual YAML files with basic information (title, date, description). The website handles everything else - displaying comics with navigation, creating an archive, generating an RSS feed, and making your comics shareable on social media.
|
||||||
|
|
||||||
No coding knowledge required for basic use - just follow the instructions below to add comics and customize your site's appearance.
|
No coding knowledge required for basic use - just follow the instructions below to add comics and customize your site's appearance.
|
||||||
|
|
||||||
@@ -31,7 +91,7 @@ No coding knowledge required for basic use - just follow the instructions below
|
|||||||
|
|
||||||
**Sunday Comics:**
|
**Sunday Comics:**
|
||||||
- Server-side application (Flask/Python) that runs on a web server
|
- Server-side application (Flask/Python) that runs on a web server
|
||||||
- Comics stored in a Python file - edit text to add comics
|
- Comics stored as individual YAML files - easy version control and management
|
||||||
- Includes an RSS feed generator and helper scripts
|
- Includes an RSS feed generator and helper scripts
|
||||||
- API endpoints for programmatic access
|
- API endpoints for programmatic access
|
||||||
- Markdown support for rich-formatted content
|
- Markdown support for rich-formatted content
|
||||||
@@ -40,7 +100,7 @@ No coding knowledge required for basic use - just follow the instructions below
|
|||||||
|
|
||||||
**Rarebit:**
|
**Rarebit:**
|
||||||
- Purely static HTML/CSS/JavaScript files
|
- Purely static HTML/CSS/JavaScript files
|
||||||
- Comics are inferred from static images upload - edit a JS to customize
|
- Comics are inferred from static images upload - edit a JS file to customize
|
||||||
- Can be hosted for free on GitHub Pages, Neocities, etc.
|
- Can be hosted for free on GitHub Pages, Neocities, etc.
|
||||||
- No server or programming language required
|
- No server or programming language required
|
||||||
- Simpler deployment - just upload files
|
- Simpler deployment - just upload files
|
||||||
@@ -98,6 +158,7 @@ Don't have a server? No problem! Here are beginner-friendly options to get your
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Comic viewer with navigation (First, Previous, Next, Latest)
|
- Comic viewer with navigation (First, Previous, Next, Latest)
|
||||||
|
- **Multi-image comics** with vertical scrolling (webtoon style) and lazy loading
|
||||||
- Client-side navigation using JSON API (no page reloads)
|
- Client-side navigation using JSON API (no page reloads)
|
||||||
- Keyboard navigation support (arrow keys, Home/End)
|
- Keyboard navigation support (arrow keys, Home/End)
|
||||||
- Archive page with thumbnail grid
|
- Archive page with thumbnail grid
|
||||||
@@ -105,27 +166,491 @@ Don't have a server? No problem! Here are beginner-friendly options to get your
|
|||||||
- Markdown support for author notes and about page
|
- Markdown support for author notes and about page
|
||||||
- Optional icon-based navigation (comic navigation, header, and social links)
|
- Optional icon-based navigation (comic navigation, header, and social links)
|
||||||
- Configurable logo and header/footer images
|
- Configurable logo and header/footer images
|
||||||
|
- Shareable banner button with embeddable HTML code
|
||||||
- Mobile-responsive with optional mobile-specific comic images
|
- Mobile-responsive with optional mobile-specific comic images
|
||||||
- Full-width and plain (headerless) display modes
|
- Full-width and plain (headerless) display modes
|
||||||
- JSON API for programmatic access
|
- JSON API for programmatic access
|
||||||
- Open Graph and Twitter Card metadata for social sharing
|
- Open Graph and Twitter Card metadata for social sharing
|
||||||
- Server-side rendering with Jinja2
|
- Server-side rendering with Jinja2
|
||||||
|
- **Comprehensive accessibility features** (WCAG compliant)
|
||||||
|
- **Search engine optimized** (sitemap, robots.txt, meta tags, canonical URLs)
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
Sunday Comics is built with accessibility as a core feature, ensuring your webcomic can be enjoyed by all readers, including those using assistive technologies.
|
||||||
|
|
||||||
|
### Accessibility Features
|
||||||
|
|
||||||
|
#### ✅ Keyboard Navigation
|
||||||
|
- **Full keyboard support** - Navigate the entire site without a mouse
|
||||||
|
- **Visible focus indicators** - Clear visual outlines on all interactive elements when using Tab navigation
|
||||||
|
- **Skip to main content** - Press Tab on any page to reveal a "Skip to main content" link, allowing keyboard users to bypass navigation
|
||||||
|
- **Arrow key shortcuts** - Use Left/Right arrows to navigate between comics, Home/End for first/latest
|
||||||
|
- **Focus management** - Keyboard focus automatically moves to the comic content after navigation, maintaining context for screen reader users
|
||||||
|
|
||||||
|
#### ✅ Screen Reader Support
|
||||||
|
- **Semantic HTML** - Proper use of `<header>`, `<nav>`, `<main>`, `<footer>` elements
|
||||||
|
- **ARIA live regions** - Screen readers announce when new comics load during client-side navigation
|
||||||
|
- **ARIA labels** - All icon buttons and links have descriptive labels
|
||||||
|
- **ARIA disabled states** - Disabled navigation buttons properly announce their state
|
||||||
|
- **Alt text** - All comic images require alt text (set via `alt_text` field in `comics_data.py`)
|
||||||
|
- **No redundant announcements** - Decorative icons use empty alt text to avoid duplicate screen reader announcements
|
||||||
|
|
||||||
|
#### ✅ Visual Accessibility
|
||||||
|
- **High contrast** - Focus indicators use solid 3px outlines for visibility
|
||||||
|
- **Responsive design** - Works across desktop, tablet, and mobile screen sizes
|
||||||
|
- **No reliance on color alone** - Disabled states use both color and opacity changes
|
||||||
|
|
||||||
|
#### ✅ Additional Accessibility Support
|
||||||
|
- **Language declaration** - `lang="en"` attribute on HTML element
|
||||||
|
- **Heading hierarchy** - Proper use of h1, h2, h3 throughout the site
|
||||||
|
- **Clickable regions** - Comic images that advance to the next comic include descriptive labels
|
||||||
|
- **External link safety** - `rel="noopener noreferrer"` on external links
|
||||||
|
- **Keyboard trap prevention** - No focus traps; users can always navigate away
|
||||||
|
|
||||||
|
### Testing Accessibility
|
||||||
|
|
||||||
|
To test keyboard navigation on your site:
|
||||||
|
|
||||||
|
1. **Tab Navigation**
|
||||||
|
- Press Tab repeatedly to move through interactive elements
|
||||||
|
- Verify visible focus indicators appear on links and buttons
|
||||||
|
- First Tab should reveal "Skip to main content" link
|
||||||
|
|
||||||
|
2. **Keyboard Shortcuts**
|
||||||
|
- On a comic page, press Right Arrow to advance
|
||||||
|
- Press Left Arrow to go back
|
||||||
|
- Press Home to jump to first comic
|
||||||
|
- Press End to jump to latest comic
|
||||||
|
|
||||||
|
3. **Screen Reader Testing** (Optional)
|
||||||
|
- **macOS:** Enable VoiceOver (Cmd + F5)
|
||||||
|
- **Windows:** Use NVDA (free) or JAWS
|
||||||
|
- Navigate through the site and verify announcements are clear and logical
|
||||||
|
|
||||||
|
### Accessibility Best Practices for Comic Creators
|
||||||
|
|
||||||
|
When adding comics to your site, follow these guidelines to maintain accessibility:
|
||||||
|
|
||||||
|
1. **Always provide alt text**
|
||||||
|
```yaml
|
||||||
|
number: 1
|
||||||
|
filename: comic-001.png
|
||||||
|
alt_text: A descriptive summary of what happens in the comic # Required!
|
||||||
|
date: '2025-01-01'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Write meaningful alt text**
|
||||||
|
- Describe the comic's content and context
|
||||||
|
- Include dialogue if it's essential to understanding
|
||||||
|
- Keep it concise but descriptive (aim for 1-2 sentences)
|
||||||
|
- Bad: "Comic"
|
||||||
|
- Good: "Sarah discovers her cat can talk and is planning world domination"
|
||||||
|
|
||||||
|
3. **Use proper image formats**
|
||||||
|
- Ensure comic images are clear and readable
|
||||||
|
- Consider providing larger resolution images for zoom accessibility
|
||||||
|
- Test your comics at different zoom levels (200%, 300%)
|
||||||
|
|
||||||
|
4. **Structure author notes clearly**
|
||||||
|
- Use markdown headings for long author notes
|
||||||
|
- Break up long paragraphs
|
||||||
|
- Use lists for clarity when appropriate
|
||||||
|
|
||||||
|
### Accessibility Score
|
||||||
|
|
||||||
|
Sunday Comics follows WCAG 2.1 Level AA guidelines and scores **9.5/10** in accessibility compliance, with comprehensive support for:
|
||||||
|
- Keyboard navigation ✅
|
||||||
|
- Screen reader compatibility ✅
|
||||||
|
- Focus management ✅
|
||||||
|
- ARIA attributes ✅
|
||||||
|
- Semantic HTML ✅
|
||||||
|
|
||||||
|
## Search Engine Optimization (SEO)
|
||||||
|
|
||||||
|
Sunday Comics is built with SEO best practices to help readers discover your webcomic through search engines and social media.
|
||||||
|
|
||||||
|
### SEO Features
|
||||||
|
|
||||||
|
#### ✅ Sitemap & Crawling
|
||||||
|
- **XML Sitemap** - Automatically generated sitemap that includes all comics, archive, and about pages
|
||||||
|
- **Robots.txt** - Dynamically generated with correct URLs for your domain
|
||||||
|
- **Canonical URLs** - Every page has a canonical URL to prevent duplicate content issues
|
||||||
|
- **Semantic HTML** - Proper use of heading hierarchy (h1, h2, h3) for better indexing
|
||||||
|
- **Language declaration** - `lang="en"` attribute helps search engines understand your content
|
||||||
|
|
||||||
|
#### ✅ Meta Tags & Descriptions
|
||||||
|
- **Meta descriptions** - Customizable descriptions for each page
|
||||||
|
- **Page titles** - SEO-friendly title format: "Comic Title - Your Comic Name"
|
||||||
|
- **RSS feed link** - Included in HTML head for feed discovery
|
||||||
|
- **Viewport meta tag** - Mobile-friendly configuration
|
||||||
|
|
||||||
|
#### ✅ Social Media Sharing
|
||||||
|
- **Open Graph tags** - Optimized previews when sharing on Facebook, LinkedIn, Discord, etc.
|
||||||
|
- **Twitter Cards** - Rich media cards with images when sharing on Twitter/X
|
||||||
|
- **Custom preview images** - Set comic-specific images for social sharing
|
||||||
|
- **Shareable URLs** - Clean, readable URLs like `/comic/1` instead of query parameters
|
||||||
|
|
||||||
|
#### ✅ Content Optimization
|
||||||
|
- **Alt text requirement** - All comics must have descriptive alt text (helps SEO and accessibility)
|
||||||
|
- **Structured data** - Semantic HTML provides context to search engines
|
||||||
|
- **Mobile-responsive** - Mobile-friendly design is a ranking factor for Google
|
||||||
|
- **Fast loading** - Server-side rendering provides quick initial page loads
|
||||||
|
|
||||||
|
### Using SEO Features
|
||||||
|
|
||||||
|
#### Generate Your Sitemap
|
||||||
|
|
||||||
|
After adding or updating comics, regenerate your sitemap:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_sitemap.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `static/sitemap.xml` with:
|
||||||
|
- Homepage (priority: 1.0, updated weekly)
|
||||||
|
- Archive page (priority: 0.9, updated weekly)
|
||||||
|
- About page (priority: 0.7, updated monthly)
|
||||||
|
- Individual comic pages (priority: 0.8, never updated)
|
||||||
|
|
||||||
|
**Submit your sitemap to search engines:**
|
||||||
|
- Google Search Console: `https://search.google.com/search-console`
|
||||||
|
- Bing Webmaster Tools: `https://www.bing.com/webmasters`
|
||||||
|
|
||||||
|
Your sitemap URL will be: `https://yourdomain.com/sitemap.xml`
|
||||||
|
|
||||||
|
#### Configure robots.txt
|
||||||
|
|
||||||
|
The robots.txt file is automatically generated at `/robots.txt` with your correct domain from `SITE_URL` in `comics_data.py`. It includes:
|
||||||
|
- Sitemap location
|
||||||
|
- Allows all search engine crawlers
|
||||||
|
- No disallowed paths (everything is indexable)
|
||||||
|
|
||||||
|
**Update your domain:**
|
||||||
|
```python
|
||||||
|
# In comics_data.py
|
||||||
|
SITE_URL = 'https://yourcomic.com' # Update this for production!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Customize Meta Descriptions
|
||||||
|
|
||||||
|
By default, pages use a generic description. To customize, edit the templates:
|
||||||
|
|
||||||
|
**templates/base.html** - Default description (line 9):
|
||||||
|
```html
|
||||||
|
<meta name="description" content="Your custom description here">
|
||||||
|
```
|
||||||
|
|
||||||
|
**templates/comic.html** - Comic-specific descriptions:
|
||||||
|
```html
|
||||||
|
{% block meta_description %}{{ comic.alt_text }}{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
**templates/index.html** - Homepage description:
|
||||||
|
```html
|
||||||
|
{% block meta_description %}Read the latest comic from Your Comic Name{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Optimize Social Sharing
|
||||||
|
|
||||||
|
**Set a default preview image:**
|
||||||
|
|
||||||
|
1. Create a 1200x630px image (recommended size for Open Graph)
|
||||||
|
2. Save it in `static/images/` (e.g., `default-preview.png`)
|
||||||
|
3. Update `base.html` line 17:
|
||||||
|
```html
|
||||||
|
<meta property="og:image" content="{{ site_url }}/static/images/your-preview.png">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comic-specific sharing images:**
|
||||||
|
|
||||||
|
The `comic.html` template automatically uses each comic's image for social sharing. When someone shares a comic link, the actual comic image appears in the preview.
|
||||||
|
|
||||||
|
#### Generate RSS Feed
|
||||||
|
|
||||||
|
RSS feeds help readers subscribe and improve discoverability:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate_rss.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `static/feed.rss` which is automatically linked in the HTML head. Readers can subscribe via:
|
||||||
|
- `https://yourdomain.com/static/feed.rss`
|
||||||
|
|
||||||
|
RSS feeds also signal to search engines that your site has regularly updated content.
|
||||||
|
|
||||||
|
### SEO Best Practices for Webcomics
|
||||||
|
|
||||||
|
#### 1. Write Descriptive Alt Text
|
||||||
|
Alt text serves dual purpose - accessibility AND SEO:
|
||||||
|
- Include character names and actions
|
||||||
|
- Describe the scene setting
|
||||||
|
- Mention any important dialogue or text
|
||||||
|
- Keep it concise but informative (1-3 sentences)
|
||||||
|
|
||||||
|
**Good examples:**
|
||||||
|
- ✅ "Sarah discovers her cat plotting world domination while reading a mysterious ancient book"
|
||||||
|
- ✅ "The hero confronts the villain in a dramatic rooftop battle at sunset"
|
||||||
|
- ❌ "Comic" (too vague)
|
||||||
|
- ❌ "Comic about a cat" (not descriptive enough)
|
||||||
|
|
||||||
|
#### 2. Use Meaningful Comic Titles
|
||||||
|
If you set a title for each comic (optional), make it:
|
||||||
|
- Descriptive of the comic's content or theme
|
||||||
|
- Unique (don't reuse titles)
|
||||||
|
- Natural language (avoid "Comic #1" style)
|
||||||
|
- Under 60 characters for best search result display
|
||||||
|
|
||||||
|
#### 3. Update Regularly
|
||||||
|
- Consistent publishing schedule signals quality to search engines
|
||||||
|
- Run `generate_sitemap.py` after each new comic
|
||||||
|
- Update your about page periodically
|
||||||
|
|
||||||
|
#### 4. Build Backlinks
|
||||||
|
- List your comic in webcomic directories
|
||||||
|
- Use the shareable banner feature to encourage fan sites to link back
|
||||||
|
- Engage with other webcomic communities
|
||||||
|
- Share on social media regularly
|
||||||
|
|
||||||
|
#### 5. Monitor Your SEO
|
||||||
|
Free tools to track your webcomic's search performance:
|
||||||
|
- **Google Search Console** - See how people find your site, track indexing issues
|
||||||
|
- **Bing Webmaster Tools** - Similar to Google, for Bing search
|
||||||
|
- **Google Analytics** - Track visitor behavior (requires separate setup)
|
||||||
|
|
||||||
|
#### 6. Domain and URL Structure
|
||||||
|
- Use a memorable domain name
|
||||||
|
- Keep URLs clean: `yourcomic.com/comic/1` ✅ vs `yourcomic.com/?p=1` ❌
|
||||||
|
- Sunday Comics uses SEO-friendly URLs by default
|
||||||
|
|
||||||
|
#### 7. Page Speed
|
||||||
|
- Optimize comic images (use compression, appropriate file formats)
|
||||||
|
- Consider creating thumbnails for the archive page
|
||||||
|
- Use a CDN for static assets if you have high traffic
|
||||||
|
- Sunday Comics uses server-side rendering for fast initial loads
|
||||||
|
|
||||||
|
### SEO Checklist for Launch
|
||||||
|
|
||||||
|
Before going live with your webcomic:
|
||||||
|
|
||||||
|
- [ ] Update `SITE_URL` in `comics_data.py` to your production domain
|
||||||
|
- [ ] Generate sitemap: `python scripts/generate_sitemap.py`
|
||||||
|
- [ ] Generate RSS feed: `python scripts/generate_rss.py`
|
||||||
|
- [ ] Customize meta description in `templates/base.html`
|
||||||
|
- [ ] Create a 1200x630px social preview image
|
||||||
|
- [ ] Write descriptive alt text for all comics
|
||||||
|
- [ ] Set up Google Search Console account
|
||||||
|
- [ ] Submit your sitemap to Google Search Console
|
||||||
|
- [ ] Submit your sitemap to Bing Webmaster Tools
|
||||||
|
- [ ] Test social sharing on Facebook, Twitter, Discord
|
||||||
|
- [ ] Update your about page with relevant keywords
|
||||||
|
- [ ] Set up RSS feed in Feedly or similar to verify it works
|
||||||
|
|
||||||
|
### Common SEO Questions
|
||||||
|
|
||||||
|
**Q: How long until my comic appears in Google?**
|
||||||
|
A: Typically 1-4 weeks after submitting your sitemap. You can request indexing in Google Search Console to speed this up.
|
||||||
|
|
||||||
|
**Q: Should I include keywords in my comic alt text?**
|
||||||
|
A: Only naturally. Focus on accurate descriptions first. Keyword stuffing hurts SEO and accessibility.
|
||||||
|
|
||||||
|
**Q: Do I need to regenerate the sitemap for every comic?**
|
||||||
|
A: Yes, run `python scripts/generate_sitemap.py` after adding new comics so search engines know about them.
|
||||||
|
|
||||||
|
**Q: What about social media hashtags?**
|
||||||
|
A: Hashtags don't directly affect search engine SEO, but they help social media discoverability. Use relevant community hashtags like #webcomics #comics #indiecomics.
|
||||||
|
|
||||||
|
**Q: Should I create a blog for my comic?**
|
||||||
|
A: Optional, but regular blog content about your comic's development can improve SEO through fresh content and more keywords.
|
||||||
|
|
||||||
|
## Content Protection & AI Scraping Prevention
|
||||||
|
|
||||||
|
Sunday Comics includes built-in measures to discourage AI web scrapers from using your creative work for training machine learning models without permission.
|
||||||
|
|
||||||
|
### Protection Features
|
||||||
|
|
||||||
|
#### robots.txt Blocking
|
||||||
|
The dynamically generated `robots.txt` file blocks known AI crawlers while still allowing legitimate search engines:
|
||||||
|
|
||||||
|
**Blocked AI bots:**
|
||||||
|
- **GPTBot** & **ChatGPT-User** (OpenAI)
|
||||||
|
- **CCBot** (Common Crawl - used by many AI companies)
|
||||||
|
- **anthropic-ai** & **Claude-Web** (Anthropic)
|
||||||
|
- **Google-Extended** (Google's AI training crawler, separate from Googlebot)
|
||||||
|
- **PerplexityBot** (Perplexity AI)
|
||||||
|
- **Omgilibot**, **Diffbot**, **Bytespider**, **FacebookBot**, **ImagesiftBot**, **cohere-ai**
|
||||||
|
|
||||||
|
**Note:** Regular search engine crawlers (Googlebot, Bingbot, etc.) are still allowed so your comic can be discovered through search.
|
||||||
|
|
||||||
|
The robots.txt also includes a reference to your Terms of Service for transparency.
|
||||||
|
|
||||||
|
#### HTML Meta Tags
|
||||||
|
Every page includes meta tags that signal to AI scrapers not to use the content:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="robots" content="noai, noimageai">
|
||||||
|
<meta name="googlebot" content="noai, noimageai">
|
||||||
|
```
|
||||||
|
|
||||||
|
- `noai` - Prevents AI training on text content
|
||||||
|
- `noimageai` - Prevents AI training on images (your comics)
|
||||||
|
|
||||||
|
#### Terms of Service
|
||||||
|
A comprehensive Terms of Service page at `/terms` legally prohibits:
|
||||||
|
- Using content for AI training or machine learning
|
||||||
|
- Scraping or harvesting content for datasets
|
||||||
|
- Creating derivative works using AI trained on your content
|
||||||
|
- Text and Data Mining (TDM) without permission
|
||||||
|
|
||||||
|
The Terms page is automatically linked in your footer and includes:
|
||||||
|
- Copyright protection assertions
|
||||||
|
- DMCA enforcement information
|
||||||
|
- TDM rights reservation (EU Directive 2019/790 Article 4)
|
||||||
|
- Clear permitted use guidelines
|
||||||
|
|
||||||
|
#### HTTP Headers
|
||||||
|
Sunday Comics automatically adds `X-Robots-Tag: noai, noimageai` headers to all responses for additional AI blocking enforcement.
|
||||||
|
|
||||||
|
#### TDM Reservation File
|
||||||
|
The `/tdmrep.json` endpoint formally reserves Text and Data Mining rights under EU Directive 2019/790, pointing to your Terms of Service.
|
||||||
|
|
||||||
|
### Advanced: Image-Level Protection Tools
|
||||||
|
|
||||||
|
For artists who want to protect their work at the image level, consider these specialized tools:
|
||||||
|
|
||||||
|
#### Glaze (Style Protection)
|
||||||
|
**What it does:** Adds imperceptible changes to images that prevent AI models from accurately learning your artistic style.
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Protecting your unique art style from being copied by AI
|
||||||
|
- Making AI-generated imitations look wrong or distorted
|
||||||
|
- Artists concerned about style mimicry (e.g., "draw like [artist name]" prompts)
|
||||||
|
|
||||||
|
**How to use:**
|
||||||
|
1. Download from [glaze.cs.uchicago.edu](https://glaze.cs.uchicago.edu)
|
||||||
|
2. Process your comic images before uploading to your site
|
||||||
|
3. The changes are invisible to humans but confuse AI models
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Processing time: Can take several minutes per image
|
||||||
|
- Slight file size increase
|
||||||
|
- Requires reprocessing all comics
|
||||||
|
|
||||||
|
#### Nightshade (Data Poisoning)
|
||||||
|
**What it does:** Makes images appear as something completely different to AI models while looking normal to humans.
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Active defense against unauthorized AI training
|
||||||
|
- Making scraped data actively harmful to AI models
|
||||||
|
- Artists who want to fight back against scraping
|
||||||
|
|
||||||
|
**How to use:**
|
||||||
|
1. Download from [nightshade.cs.uchicago.edu](https://nightshade.cs.uchicago.edu)
|
||||||
|
2. Process images before uploading (can combine with Glaze)
|
||||||
|
3. AI models trained on these images will produce incorrect results
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- More aggressive than Glaze (may violate some ToS)
|
||||||
|
- Processing time similar to Glaze
|
||||||
|
- Ongoing research tool, effectiveness may vary
|
||||||
|
|
||||||
|
#### Recommendations
|
||||||
|
- **Use Glaze if:** You want passive protection for your art style
|
||||||
|
- **Use Nightshade if:** You want active defense and accept the risks
|
||||||
|
- **Use both if:** Maximum protection is your priority
|
||||||
|
- **Combine with Sunday Comics protections:** These tools complement the web-based protections (robots.txt, meta tags, etc.)
|
||||||
|
|
||||||
|
**Note:** Both tools are free, open-source projects from the University of Chicago's SAND Lab, specifically designed to help artists protect their work from AI exploitation.
|
||||||
|
|
||||||
|
### Important Limitations
|
||||||
|
|
||||||
|
**These measures are voluntary** - they only work if AI companies respect them:
|
||||||
|
|
||||||
|
✅ **What this does:**
|
||||||
|
- Signals your intent to protect your content
|
||||||
|
- Provides legal grounding for DMCA takedowns
|
||||||
|
- Blocks responsible AI companies that honor robots.txt
|
||||||
|
- Makes your copyright stance clear to users and crawlers
|
||||||
|
|
||||||
|
❌ **What this doesn't do:**
|
||||||
|
- Cannot physically prevent determined bad actors from scraping
|
||||||
|
- Cannot remove already-scraped historical data from existing datasets
|
||||||
|
- No guarantee all AI companies will honor these signals
|
||||||
|
|
||||||
|
**Companies that claim to honor robots.txt:**
|
||||||
|
- OpenAI (GPTBot blocking)
|
||||||
|
- Anthropic (anthropic-ai blocking)
|
||||||
|
- Google (Google-Extended blocking, separate from search)
|
||||||
|
|
||||||
|
### Customizing Your Terms
|
||||||
|
|
||||||
|
Edit `/Users/pori/PycharmProjects/sunday/content/terms.md` to customize:
|
||||||
|
|
||||||
|
1. **Jurisdiction** - Add your country/state for legal clarity
|
||||||
|
2. **Permitted use** - Adjust what you allow (fan art, sharing, etc.)
|
||||||
|
3. **Contact info** - Automatically populated from `comics_data.py`
|
||||||
|
|
||||||
|
The Terms page uses Jinja2 template variables that pull from your configuration:
|
||||||
|
- `{{ copyright_name }}` - From `COPYRIGHT_NAME` in `comics_data.py`
|
||||||
|
- `{{ social_email }}` - From `SOCIAL_EMAIL` in `comics_data.py`
|
||||||
|
|
||||||
|
### Testing Your Protection
|
||||||
|
|
||||||
|
**Verify robots.txt:**
|
||||||
|
```bash
|
||||||
|
curl https://yourcomic.com/robots.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see AI bot blocks and a link to your terms.
|
||||||
|
|
||||||
|
**Check meta tags:**
|
||||||
|
View page source and look for:
|
||||||
|
```html
|
||||||
|
<meta name="robots" content="noai, noimageai">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validate Terms page:**
|
||||||
|
Visit `https://yourcomic.com/terms` to ensure it renders correctly.
|
||||||
|
|
||||||
|
### Reporting Violations
|
||||||
|
|
||||||
|
If you discover your work in an AI training dataset or being used without permission:
|
||||||
|
|
||||||
|
1. **Document the violation** - Screenshots, URLs, timestamps
|
||||||
|
2. **Review their TOS** - Many AI services have content dispute processes
|
||||||
|
3. **Send DMCA takedown** - Your Terms of Service provides legal standing
|
||||||
|
4. **Contact the platform** - Use your `SOCIAL_EMAIL` from the Terms page
|
||||||
|
|
||||||
|
Resources:
|
||||||
|
- [US Copyright Office DMCA](https://www.copyright.gov/dmca/)
|
||||||
|
- [EU Copyright Directive](https://digital-strategy.ec.europa.eu/en/policies/copyright-legislation)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
sunday/
|
sunday/
|
||||||
├── app.py # Main Flask application
|
├── app.py # Main Flask application
|
||||||
├── comics_data.py # Comic data and configuration
|
├── comics_data.py # Global configuration (not comic data)
|
||||||
|
├── data_loader.py # YAML comic loader with caching
|
||||||
├── requirements.txt # Python dependencies
|
├── requirements.txt # Python dependencies
|
||||||
├── Dockerfile # Production Docker image
|
├── Dockerfile # Production Docker image
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
├── .dockerignore # Docker build exclusions
|
├── .dockerignore # Docker build exclusions
|
||||||
|
├── data/ # Comic data directory
|
||||||
|
│ └── comics/ # Individual comic YAML files
|
||||||
|
│ ├── 001.yaml # Comic #1
|
||||||
|
│ ├── 002.yaml # Comic #2
|
||||||
|
│ ├── TEMPLATE.yaml # Template for new comics
|
||||||
|
│ └── .comics_cache.pkl # Auto-generated cache file
|
||||||
├── scripts/ # Utility scripts
|
├── scripts/ # Utility scripts
|
||||||
│ ├── add_comic.py # Script to add new comic entries
|
│ ├── add_comic.py # Create new comic YAML files
|
||||||
│ └── generate_rss.py # Script to generate RSS feed
|
│ ├── generate_rss.py # Generate RSS feed
|
||||||
|
│ ├── generate_sitemap.py # Generate sitemap.xml
|
||||||
|
│ ├── rebuild_cache.py # Force rebuild comics cache
|
||||||
|
│ └── publish_comic.py # Rebuild cache + RSS + sitemap
|
||||||
├── content/ # Markdown content
|
├── content/ # Markdown content
|
||||||
│ ├── about.md # About page content
|
│ ├── about.md # About page content
|
||||||
|
│ ├── terms.md # Terms of Service
|
||||||
│ └── author_notes/ # Author notes for comics (by date)
|
│ └── author_notes/ # Author notes for comics (by date)
|
||||||
├── templates/ # Jinja2 templates
|
├── templates/ # Jinja2 templates
|
||||||
│ ├── base.html # Base template with navigation
|
│ ├── base.html # Base template with navigation
|
||||||
@@ -143,7 +668,8 @@ sunday/
|
|||||||
│ ├── comics/ # Comic images
|
│ ├── comics/ # Comic images
|
||||||
│ ├── thumbs/ # Thumbnail images for archive
|
│ ├── thumbs/ # Thumbnail images for archive
|
||||||
│ └── icons/ # Navigation and social icons (optional)
|
│ └── icons/ # Navigation and social icons (optional)
|
||||||
└── feed.rss # RSS feed (generated)
|
├── feed.rss # RSS feed (generated)
|
||||||
|
└── sitemap.xml # Sitemap (generated)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -167,6 +693,7 @@ The app can be configured via environment variables:
|
|||||||
- `SECRET_KEY` - Flask secret key (defaults to 'your-secret-key')
|
- `SECRET_KEY` - Flask secret key (defaults to 'your-secret-key')
|
||||||
- `PORT` - Port to run on (defaults to 3000)
|
- `PORT` - Port to run on (defaults to 3000)
|
||||||
- `DEBUG` - Enable debug mode (defaults to False)
|
- `DEBUG` - Enable debug mode (defaults to False)
|
||||||
|
- `DISABLE_COMIC_CACHE` - Set to 'true' to disable comic caching (useful for debugging)
|
||||||
|
|
||||||
**Development:**
|
**Development:**
|
||||||
```bash
|
```bash
|
||||||
@@ -181,14 +708,21 @@ export PORT=3000
|
|||||||
python app.py
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Disable caching (debugging):**
|
||||||
|
```bash
|
||||||
|
export DISABLE_COMIC_CACHE=true
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The `comics_data.py` file contains both comic data and global configuration options:
|
The `comics_data.py` file contains global configuration options for your comic site. Comic data itself is stored in individual YAML files in the `data/comics/` directory.
|
||||||
|
|
||||||
### Global Settings
|
### Global Settings
|
||||||
|
|
||||||
```python
|
```python
|
||||||
COMIC_NAME = 'Sunday Comics' # Your comic/website name
|
COMIC_NAME = 'Sunday Comics' # Your comic/website name
|
||||||
|
COPYRIGHT_NAME = None # Name for copyright (defaults to COMIC_NAME)
|
||||||
SITE_URL = 'http://localhost:3000' # Your domain (update for production)
|
SITE_URL = 'http://localhost:3000' # Your domain (update for production)
|
||||||
FULL_WIDTH_DEFAULT = False # Make all comics full-width by default
|
FULL_WIDTH_DEFAULT = False # Make all comics full-width by default
|
||||||
PLAIN_DEFAULT = False # Hide header/remove borders by default
|
PLAIN_DEFAULT = False # Hide header/remove borders by default
|
||||||
@@ -196,11 +730,17 @@ LOGO_IMAGE = 'logo.png' # Path to logo (relative to static/ima
|
|||||||
LOGO_MODE = 'beside' # 'beside' or 'replace' title with logo
|
LOGO_MODE = 'beside' # 'beside' or 'replace' title with logo
|
||||||
HEADER_IMAGE = None # Optional header image path
|
HEADER_IMAGE = None # Optional header image path
|
||||||
FOOTER_IMAGE = None # Optional footer image path
|
FOOTER_IMAGE = None # Optional footer image path
|
||||||
|
BANNER_IMAGE = 'banner.jpg' # Shareable banner for "Link to Me" section
|
||||||
COMPACT_FOOTER = False # Display footer in compact mode
|
COMPACT_FOOTER = False # Display footer in compact mode
|
||||||
ARCHIVE_FULL_WIDTH = True # Full-width archive with 4 columns
|
ARCHIVE_FULL_WIDTH = True # Full-width archive with 4 columns
|
||||||
|
SECTIONS_ENABLED = True # Enable section headers on archive page
|
||||||
USE_COMIC_NAV_ICONS = True # Use icons for comic navigation buttons
|
USE_COMIC_NAV_ICONS = True # Use icons for comic navigation buttons
|
||||||
USE_HEADER_NAV_ICONS = True # Show icons in main header navigation
|
USE_HEADER_NAV_ICONS = True # Show icons in main header navigation
|
||||||
USE_FOOTER_SOCIAL_ICONS = True # Use icons for social links
|
USE_FOOTER_SOCIAL_ICONS = True # Use icons for social links
|
||||||
|
USE_SHARE_ICONS = True # Use icons in share buttons (permalink/embed)
|
||||||
|
NEWSLETTER_ENABLED = False # Show newsletter section in footer
|
||||||
|
EMBED_ENABLED = True # Enable comic embed functionality
|
||||||
|
PERMALINK_ENABLED = True # Enable permalink copy button
|
||||||
SOCIAL_INSTAGRAM = None # Instagram URL (or None)
|
SOCIAL_INSTAGRAM = None # Instagram URL (or None)
|
||||||
SOCIAL_YOUTUBE = None # YouTube URL (or None)
|
SOCIAL_YOUTUBE = None # YouTube URL (or None)
|
||||||
SOCIAL_EMAIL = None # Email mailto link (or None)
|
SOCIAL_EMAIL = None # Email mailto link (or None)
|
||||||
@@ -209,67 +749,107 @@ API_SPEC_LINK = None # API documentation link (or None)
|
|||||||
|
|
||||||
## Adding Comics
|
## Adding Comics
|
||||||
|
|
||||||
Comics are stored in the `COMICS` list in `comics_data.py`. Each comic entry:
|
Comics are stored as individual YAML files in the `data/comics/` directory. Each comic file contains:
|
||||||
|
|
||||||
```python
|
```yaml
|
||||||
{
|
number: 1 # Comic number (required, sequential)
|
||||||
'number': 1, # Comic number (required, sequential)
|
filename: comic-001.png # Image filename (required) OR list for multi-image
|
||||||
'filename': 'comic-001.png', # Image filename (required)
|
date: '2025-01-01' # Publication date (required)
|
||||||
'mobile_filename': 'comic-001-mobile.png', # Optional mobile version
|
alt_text: Alt text for comic # Accessibility text (required) OR list for multi-image
|
||||||
'date': '2025-01-01', # Publication date (required)
|
title: Comic Title # Title (optional, shows #X if absent)
|
||||||
'alt_text': 'Alt text for comic', # Accessibility text (required)
|
author_note: Optional note # Author note (optional, plain text)
|
||||||
'title': 'Comic Title', # Title (optional, shows #X if absent)
|
author_note_md: 2025-01-01.md # Optional markdown file for author note
|
||||||
'author_note': 'Optional note', # Author note (optional, plain text)
|
full_width: true # Optional: override FULL_WIDTH_DEFAULT
|
||||||
'author_note_md': '2025-01-01.md', # Optional markdown file for author note
|
plain: true # Optional: override PLAIN_DEFAULT
|
||||||
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT
|
```
|
||||||
'plain': True # Optional: override PLAIN_DEFAULT
|
|
||||||
}
|
**For multi-image comics (webtoon style):**
|
||||||
|
```yaml
|
||||||
|
number: 2
|
||||||
|
filename:
|
||||||
|
- page1.png
|
||||||
|
- page2.png
|
||||||
|
- page3.png
|
||||||
|
alt_text:
|
||||||
|
- Panel 1 description
|
||||||
|
- Panel 2 description
|
||||||
|
- Panel 3 description
|
||||||
|
date: '2025-01-08'
|
||||||
|
full_width: true # Recommended for webtoons
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding a New Comic
|
### Adding a New Comic
|
||||||
|
|
||||||
**Option 1: Use the script (recommended)**
|
**Option 1: Use the script (recommended)**
|
||||||
```bash
|
```bash
|
||||||
# Add comic entry only
|
# Add comic YAML file with defaults
|
||||||
python scripts/add_comic.py
|
python scripts/add_comic.py
|
||||||
|
|
||||||
# Add comic entry AND create markdown file for author notes
|
# Add comic YAML file AND create markdown file for author notes
|
||||||
python scripts/add_comic.py -m
|
python scripts/add_comic.py -m
|
||||||
```
|
```
|
||||||
This will automatically add a new entry with defaults. The `-m` flag creates a markdown file in `content/author_notes/{date}.md` with a template and adds the `author_note_md` field to the comic entry. Then edit `comics_data.py` to customize.
|
This will create a new YAML file in `data/comics/` with the next comic number and reasonable defaults. The `-m` flag also creates a markdown file in `content/author_notes/{date}.md` with a template. Then:
|
||||||
|
1. Edit the generated YAML file to customize title, alt_text, author_note, etc.
|
||||||
|
2. Upload your comic image to `static/images/comics/`
|
||||||
|
3. Optionally create a thumbnail in `static/images/thumbs/` with the same filename
|
||||||
|
|
||||||
**Option 2: Manual**
|
**Option 2: Manual**
|
||||||
1. Save your comic image in `static/images/comics/` (e.g., `comic-001.png`)
|
1. Copy `data/comics/TEMPLATE.yaml` and rename it (e.g., `003.yaml`)
|
||||||
2. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename
|
2. Edit the YAML file to set comic properties
|
||||||
3. Add the comic entry to the `COMICS` list in `comics_data.py`
|
3. Save your comic image in `static/images/comics/`
|
||||||
|
4. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename
|
||||||
|
|
||||||
### Generating RSS Feed
|
### Publishing Comics
|
||||||
|
|
||||||
After adding comics, regenerate the RSS feed:
|
After adding or updating comics, use the publish script to update all generated files:
|
||||||
```bash
|
```bash
|
||||||
python scripts/generate_rss.py
|
python scripts/publish_comic.py
|
||||||
```
|
```
|
||||||
This creates/updates `static/feed.rss`
|
This convenience script rebuilds the cache and regenerates both RSS feed and sitemap in one command.
|
||||||
|
|
||||||
|
### Comic Caching System
|
||||||
|
|
||||||
|
Sunday Comics uses an automatic caching system to speed up comic loading:
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- **First load**: Parses all YAML files, saves to `data/comics/.comics_cache.pkl`
|
||||||
|
- **Subsequent loads**: Reads from cache (~100x faster)
|
||||||
|
- **Auto-invalidation**: Cache rebuilds automatically when any YAML file is modified
|
||||||
|
|
||||||
|
**Performance (1000 comics):**
|
||||||
|
- Initial load: ~2-3 seconds (builds cache)
|
||||||
|
- Subsequent loads: ~0.01 seconds (uses cache)
|
||||||
|
- Scripts share the same cache file on disk
|
||||||
|
|
||||||
|
**Manual cache management:**
|
||||||
|
```bash
|
||||||
|
# Force rebuild the cache (normally not needed)
|
||||||
|
python scripts/rebuild_cache.py
|
||||||
|
|
||||||
|
# Disable caching (for debugging)
|
||||||
|
export DISABLE_COMIC_CACHE=true
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache file is automatically excluded from git (listed in `.gitignore`).
|
||||||
|
|
||||||
### Markdown Support
|
### Markdown Support
|
||||||
|
|
||||||
**Author Notes:**
|
**Author Notes:**
|
||||||
Add the `author_note_md` field to your comic entry in `comics_data.py` to use markdown-formatted author notes. The field can be:
|
Add the `author_note_md` field to your comic YAML file to use markdown-formatted author notes. The field can be:
|
||||||
- Just a filename (e.g., `"2025-01-01.md"`) - looked up in `content/author_notes/`
|
- Just a filename (e.g., `2025-01-01.md`) - looked up in `content/author_notes/`
|
||||||
- A path relative to `content/` (e.g., `"special/intro.md"`)
|
- A path relative to `content/` (e.g., `special/intro.md`)
|
||||||
|
|
||||||
Markdown author notes take precedence over the plain text `author_note` field and render as HTML.
|
Markdown author notes take precedence over the plain text `author_note` field and render as HTML.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```yaml
|
||||||
# In comics_data.py
|
# In data/comics/001.yaml
|
||||||
{
|
number: 1
|
||||||
'number': 1,
|
filename: comic-001.png
|
||||||
'filename': 'comic-001.png',
|
date: '2025-01-01'
|
||||||
'date': '2025-01-01',
|
alt_text: First comic
|
||||||
'alt_text': 'First comic',
|
author_note_md: 2025-01-01.md # References content/author_notes/2025-01-01.md
|
||||||
'author_note_md': '2025-01-01.md' # References content/author_notes/2025-01-01.md
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -282,6 +862,102 @@ The `/about` route renders `content/about.md` as HTML. Edit this file to customi
|
|||||||
|
|
||||||
**Note:** Client-side navigation displays author notes as plain text. Markdown author notes only render properly on initial page load (server-side rendering). For full markdown rendering, users need to refresh the page or navigate directly to the comic URL.
|
**Note:** Client-side navigation displays author notes as plain text. Markdown author notes only render properly on initial page load (server-side rendering). For full markdown rendering, users need to refresh the page or navigate directly to the comic URL.
|
||||||
|
|
||||||
|
### Multi-Image Comics (Webtoon Style)
|
||||||
|
|
||||||
|
Sunday Comics supports vertical scrolling comics with multiple images stacked seamlessly, perfect for webtoon-style storytelling.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- Set `filename` to a list of image filenames instead of a single string
|
||||||
|
- Images display vertically with no gaps between them
|
||||||
|
- First image loads immediately, subsequent images lazy-load as readers scroll
|
||||||
|
- No click-through navigation on multi-image comics (use navigation buttons instead)
|
||||||
|
|
||||||
|
**Basic Example:**
|
||||||
|
```yaml
|
||||||
|
# In data/comics/004.yaml
|
||||||
|
number: 4
|
||||||
|
title: Webtoon Episode 1
|
||||||
|
filename:
|
||||||
|
- page1.jpg
|
||||||
|
- page2.jpg
|
||||||
|
- page3.jpg
|
||||||
|
alt_text: A three-part vertical story # Single alt text for all images
|
||||||
|
date: '2025-01-22'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Individual Alt Text (Recommended for Accessibility):**
|
||||||
|
```yaml
|
||||||
|
# In data/comics/005.yaml
|
||||||
|
number: 5
|
||||||
|
title: Long Scroll Episode
|
||||||
|
filename:
|
||||||
|
- scene1.png
|
||||||
|
- scene2.png
|
||||||
|
- scene3.png
|
||||||
|
- scene4.png
|
||||||
|
alt_text:
|
||||||
|
- Opening scene showing the city at dawn
|
||||||
|
- Character walking through the marketplace
|
||||||
|
- Close-up of the mysterious artifact
|
||||||
|
- Dramatic reveal of the antagonist
|
||||||
|
date: '2025-01-29'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** If you provide `alt_text` as a list, it should match the number of images in `filename`. If the counts don't match, you'll see a warning in the logs. To use the same alt text for all images, just provide a single string instead of a list.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- ✅ **Seamless vertical layout** - Images stack with no visible gaps
|
||||||
|
- ✅ **Lazy loading** - Only loads images as they scroll into view (performance optimization)
|
||||||
|
- ✅ **Responsive** - Works on desktop and mobile devices
|
||||||
|
- ✅ **Accessible** - Supports individual alt text for each image panel
|
||||||
|
- ✅ **Backward compatible** - Single-image comics continue to work as before
|
||||||
|
|
||||||
|
**Best Practices:**
|
||||||
|
1. **Image consistency** - Use the same width for all images in a multi-image comic for best results
|
||||||
|
2. **Alt text per panel** - Provide individual alt text for each image to describe what's happening in that section
|
||||||
|
3. **File naming** - Use descriptive, sequential names like `comic-004-panel-1.png`, `comic-004-panel-2.png`
|
||||||
|
4. **Image optimization** - Compress images appropriately since readers will load multiple images per comic
|
||||||
|
|
||||||
|
**Example with all options:**
|
||||||
|
```yaml
|
||||||
|
# In data/comics/006.yaml
|
||||||
|
number: 6
|
||||||
|
title: 'Chapter 2: The Journey Begins'
|
||||||
|
filename:
|
||||||
|
- ch2-001.png
|
||||||
|
- ch2-002.png
|
||||||
|
- ch2-003.png
|
||||||
|
- ch2-004.png
|
||||||
|
- ch2-005.png
|
||||||
|
alt_text:
|
||||||
|
- 'Panel 1: Hero packs their bag at sunrise'
|
||||||
|
- 'Panel 2: Saying goodbye to the village elder'
|
||||||
|
- 'Panel 3: Walking along the forest path'
|
||||||
|
- 'Panel 4: Encountering a mysterious stranger'
|
||||||
|
- 'Panel 5: Accepting a map to the ancient ruins'
|
||||||
|
date: '2025-02-05'
|
||||||
|
author_note: This was so much fun to draw! The journey arc begins.
|
||||||
|
full_width: true # Recommended for webtoon-style comics
|
||||||
|
section: Chapter 2 # Optional: mark the start of a new chapter
|
||||||
|
```
|
||||||
|
|
||||||
|
**Technical Details:**
|
||||||
|
- Images appear in the order listed in the `filename` array
|
||||||
|
- If `alt_text` is a single string, it applies to all images
|
||||||
|
- If `alt_text` is a list, it must match the length of `filename` (or it will pad with empty strings)
|
||||||
|
- The first image in the array is used as the thumbnail in the archive page
|
||||||
|
- Lazy loading uses Intersection Observer API with 50px margin for smooth loading
|
||||||
|
|
||||||
|
**When to use multi-image:**
|
||||||
|
- Long-form vertical scrolling stories (webtoons, manhwa style)
|
||||||
|
- Comics with natural panel breaks across multiple images
|
||||||
|
- Stories that benefit from vertical pacing and reveals
|
||||||
|
|
||||||
|
**When to stick with single images:**
|
||||||
|
- Traditional comic strip or page layouts
|
||||||
|
- Self-contained single-panel comics
|
||||||
|
- When you want click-through navigation on the comic image
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods:
|
For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods:
|
||||||
@@ -406,6 +1082,28 @@ SOCIAL_YOUTUBE = 'https://youtube.com/@yourchannel'
|
|||||||
SOCIAL_EMAIL = 'mailto:your@email.com'
|
SOCIAL_EMAIL = 'mailto:your@email.com'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Shareable Banner
|
||||||
|
|
||||||
|
Sunday Comics includes an old-school shareable banner button in the footer that visitors can use to link back to your site. To enable it:
|
||||||
|
|
||||||
|
1. Create a banner image (any size) and save it in `static/images/`
|
||||||
|
2. Set the `BANNER_IMAGE` variable in `comics_data.py`:
|
||||||
|
```python
|
||||||
|
BANNER_IMAGE = 'banner.jpg' # Your banner filename
|
||||||
|
```
|
||||||
|
3. Set to `None` to hide the "Link to Me" section entirely:
|
||||||
|
```python
|
||||||
|
BANNER_IMAGE = None # Disables the shareable banner
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, the banner appears in the footer with:
|
||||||
|
- A preview of the banner image
|
||||||
|
- A collapsible "Get code" section with copy-paste HTML
|
||||||
|
- Fully keyboard accessible (Tab to navigate, Space/Enter to expand)
|
||||||
|
- Automatic URL generation using your `SITE_URL` setting
|
||||||
|
|
||||||
|
Visitors can click "Get code" to reveal embeddable HTML they can paste on their own websites, creating a link back to your comic. Perfect for webrings, link exchanges, and building community!
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Sunday Comics provides multiple ways for readers to navigate through your comic:
|
Sunday Comics provides multiple ways for readers to navigate through your comic:
|
||||||
@@ -471,6 +1169,52 @@ The app exposes a JSON API for programmatic access:
|
|||||||
- Favicon generated using [favicon.io](https://favicon.io)
|
- Favicon generated using [favicon.io](https://favicon.io)
|
||||||
- Example comics sourced from Boots and Her Buddies comic strip at [Comic Book Plus](https://comicbookplus.com/?cid=2561)
|
- Example comics sourced from Boots and Her Buddies comic strip at [Comic Book Plus](https://comicbookplus.com/?cid=2561)
|
||||||
|
|
||||||
|
## AI Usage Transparency
|
||||||
|
|
||||||
|
> I want it to be clear that this section was written by a human!
|
||||||
|
|
||||||
|
This project has been built using Claude Code - an AI-assisted development tool. Considering several factors, I feel the
|
||||||
|
need to be transparent about this.
|
||||||
|
|
||||||
|
**My position on AI:**
|
||||||
|
|
||||||
|
I **do not** condone the use of AI in regard to artistic expression, communication, or as a replacement
|
||||||
|
real human beings. After all, _what is the point of being alive if we leave it all to machines?_
|
||||||
|
|
||||||
|
All artwork you see produced as assets here or in any of my other projects are made by real human beings and will
|
||||||
|
remain that way. Images generated from AI are an infringement on copyright and intellectual property. I fully believe
|
||||||
|
that the sourcing of data for models should be regulated. I also believe that human creative workers should be celebrated
|
||||||
|
and well compensated.
|
||||||
|
|
||||||
|
Being a text-only platform, my choice in Claude is their claim to be an "ethical" AI provider. Of course, we can trust
|
||||||
|
these vendors only so much. Should they act as advertised, there are also environmental considerations. My hope is that
|
||||||
|
this will improve over time. As with data sourcing, I also hope this will be regulated.
|
||||||
|
|
||||||
|
**How AI is used:**
|
||||||
|
|
||||||
|
This project's code is written primarily by AI. The application itself *does not* and *will never* have
|
||||||
|
any AI integration. When you start up the project, it is completely standalone just like any normal website. Your
|
||||||
|
content remains yours.
|
||||||
|
|
||||||
|
**So, why use AI?**
|
||||||
|
|
||||||
|
I am interested in illustrating and making comics, not so much spending my time writing code. AI is a means to an end.
|
||||||
|
Its use here is purely to produce a convenient platform to be used for artwork created by real people.
|
||||||
|
|
||||||
|
Should you choose to adopt this, the work you publish remains yours, as it should always be.
|
||||||
|
|
||||||
|
For the most part, I am comfortable with its use in technical matters. You can see that with the code on this project.
|
||||||
|
This is a sentiment I generally share with the software development industry which has naturally embraced the new
|
||||||
|
technology.
|
||||||
|
|
||||||
|
**Why bother with transparency?**
|
||||||
|
|
||||||
|
As controversial as the technology is, I believe in being transparent about its use. If you happen to like the look of
|
||||||
|
Sunday and want to use that, you should know just in case you are fully anti-AI. This is a position I completely respect.
|
||||||
|
|
||||||
|
If you have any concerns or are outright put off, I highly recommend looking at [Rarebit](https://rarebit.neocities.org/)
|
||||||
|
which is completely written by humans (and easier to get into if your technical knowledge is limited).
|
||||||
|
|
||||||
## License & Content Ownership
|
## License & Content Ownership
|
||||||
|
|
||||||
This software is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
This software is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
264
app.py
264
app.py
@@ -3,15 +3,21 @@
|
|||||||
# Licensed under the MIT License - see LICENSE file for details
|
# Licensed under the MIT License - see LICENSE file for details
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Flask, render_template, abort, jsonify, request
|
from flask import Flask, render_template, abort, jsonify, request
|
||||||
from comics_data import (
|
from comics_data import (
|
||||||
COMICS, COMIC_NAME, SITE_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, LOGO_IMAGE, LOGO_MODE,
|
COMICS, COMIC_NAME, COPYRIGHT_NAME, SITE_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, LOGO_IMAGE, LOGO_MODE,
|
||||||
HEADER_IMAGE, FOOTER_IMAGE, COMPACT_FOOTER, ARCHIVE_FULL_WIDTH, SECTIONS_ENABLED,
|
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, SOCIAL_INSTAGRAM,
|
USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS, USE_FOOTER_SOCIAL_ICONS, USE_SHARE_ICONS, NEWSLETTER_ENABLED,
|
||||||
SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK
|
SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK, EMBED_ENABLED, PERMALINK_ENABLED
|
||||||
)
|
)
|
||||||
import markdown
|
import markdown
|
||||||
|
from version import __version__
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -19,26 +25,41 @@ app = Flask(__name__)
|
|||||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key')
|
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
|
@app.context_processor
|
||||||
def inject_global_settings():
|
def inject_global_settings():
|
||||||
"""Make global settings available to all templates"""
|
"""Make global settings available to all templates"""
|
||||||
return {
|
return {
|
||||||
'comic_name': COMIC_NAME,
|
'comic_name': COMIC_NAME,
|
||||||
|
'copyright_name': COPYRIGHT_NAME if COPYRIGHT_NAME else COMIC_NAME,
|
||||||
|
'current_year': datetime.now().year,
|
||||||
'site_url': SITE_URL,
|
'site_url': SITE_URL,
|
||||||
'logo_image': LOGO_IMAGE,
|
'logo_image': LOGO_IMAGE,
|
||||||
'logo_mode': LOGO_MODE,
|
'logo_mode': LOGO_MODE,
|
||||||
'header_image': HEADER_IMAGE,
|
'header_image': HEADER_IMAGE,
|
||||||
'footer_image': FOOTER_IMAGE,
|
'footer_image': FOOTER_IMAGE,
|
||||||
|
'banner_image': BANNER_IMAGE,
|
||||||
'compact_footer': COMPACT_FOOTER,
|
'compact_footer': COMPACT_FOOTER,
|
||||||
'archive_full_width': ARCHIVE_FULL_WIDTH,
|
'archive_full_width': ARCHIVE_FULL_WIDTH,
|
||||||
'sections_enabled': SECTIONS_ENABLED,
|
'sections_enabled': SECTIONS_ENABLED,
|
||||||
'use_comic_nav_icons': USE_COMIC_NAV_ICONS,
|
'use_comic_nav_icons': USE_COMIC_NAV_ICONS,
|
||||||
'use_header_nav_icons': USE_HEADER_NAV_ICONS,
|
'use_header_nav_icons': USE_HEADER_NAV_ICONS,
|
||||||
'use_footer_social_icons': USE_FOOTER_SOCIAL_ICONS,
|
'use_footer_social_icons': USE_FOOTER_SOCIAL_ICONS,
|
||||||
|
'use_share_icons': USE_SHARE_ICONS,
|
||||||
|
'newsletter_enabled': NEWSLETTER_ENABLED,
|
||||||
'social_instagram': SOCIAL_INSTAGRAM,
|
'social_instagram': SOCIAL_INSTAGRAM,
|
||||||
'social_youtube': SOCIAL_YOUTUBE,
|
'social_youtube': SOCIAL_YOUTUBE,
|
||||||
'social_email': SOCIAL_EMAIL,
|
'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__
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -103,6 +124,47 @@ def enrich_comic(comic):
|
|||||||
enriched['plain'] = is_plain(comic)
|
enriched['plain'] = is_plain(comic)
|
||||||
enriched['formatted_date'] = format_comic_date(comic['date'])
|
enriched['formatted_date'] = format_comic_date(comic['date'])
|
||||||
|
|
||||||
|
# Normalize filename to list for multi-image support
|
||||||
|
if isinstance(comic.get('filename'), list):
|
||||||
|
enriched['filenames'] = comic['filename']
|
||||||
|
enriched['is_multi_image'] = True
|
||||||
|
else:
|
||||||
|
enriched['filenames'] = [comic['filename']] if 'filename' in comic else []
|
||||||
|
enriched['is_multi_image'] = False
|
||||||
|
|
||||||
|
# Normalize alt_text to list matching filenames
|
||||||
|
if isinstance(comic.get('alt_text'), list):
|
||||||
|
enriched['alt_texts'] = comic['alt_text']
|
||||||
|
|
||||||
|
# Warn if alt_text list doesn't match filenames length
|
||||||
|
if len(enriched['alt_texts']) != len(enriched['filenames']):
|
||||||
|
logger.warning(
|
||||||
|
f"Comic #{comic['number']}: alt_text list length ({len(enriched['alt_texts'])}) "
|
||||||
|
f"doesn't match filenames length ({len(enriched['filenames'])}). "
|
||||||
|
f"Tip: Use a single string for alt_text to apply the same text to all images, "
|
||||||
|
f"or provide a list matching the number of images."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If single alt_text string, use it for all images (this is intentional and valid)
|
||||||
|
alt_text = comic.get('alt_text', '')
|
||||||
|
enriched['alt_texts'] = [alt_text] * len(enriched['filenames'])
|
||||||
|
|
||||||
|
# Ensure alt_texts list matches filenames length (pad with empty strings if too short)
|
||||||
|
while len(enriched['alt_texts']) < len(enriched['filenames']):
|
||||||
|
enriched['alt_texts'].append('')
|
||||||
|
|
||||||
|
# Trim alt_texts if too long (extra ones won't be used anyway)
|
||||||
|
if len(enriched['alt_texts']) > len(enriched['filenames']):
|
||||||
|
enriched['alt_texts'] = enriched['alt_texts'][:len(enriched['filenames'])]
|
||||||
|
|
||||||
|
# Keep original filename and alt_text for backward compatibility (first image)
|
||||||
|
if enriched['filenames']:
|
||||||
|
enriched['filename'] = enriched['filenames'][0]
|
||||||
|
|
||||||
|
# Ensure alt_text is always a string (use first one if it's a list)
|
||||||
|
if enriched['alt_texts']:
|
||||||
|
enriched['alt_text'] = enriched['alt_texts'][0]
|
||||||
|
|
||||||
# Check for explicitly specified markdown author note file
|
# Check for explicitly specified markdown author note file
|
||||||
if 'author_note_md' in comic and comic['author_note_md']:
|
if 'author_note_md' in comic and comic['author_note_md']:
|
||||||
markdown_note = get_author_note_from_file(comic['author_note_md'])
|
markdown_note = get_author_note_from_file(comic['author_note_md'])
|
||||||
@@ -157,6 +219,19 @@ def comic(comic_id):
|
|||||||
comic=comic, total_comics=len(COMICS))
|
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):
|
def group_comics_by_section(comics_list):
|
||||||
"""Group comics by section. Returns list of (section_title, comics) tuples"""
|
"""Group comics by section. Returns list of (section_title, comics) tuples"""
|
||||||
if not SECTIONS_ENABLED:
|
if not SECTIONS_ENABLED:
|
||||||
@@ -189,14 +264,22 @@ def group_comics_by_section(comics_list):
|
|||||||
@app.route('/archive')
|
@app.route('/archive')
|
||||||
def archive():
|
def archive():
|
||||||
"""Archive page showing all comics"""
|
"""Archive page showing all comics"""
|
||||||
|
# Initial batch size for server-side rendering
|
||||||
|
initial_batch = 24
|
||||||
|
|
||||||
# Reverse order to show newest first
|
# 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
|
# Group by section if enabled
|
||||||
sections = group_comics_by_section(comics)
|
sections = group_comics_by_section(initial_comics)
|
||||||
|
|
||||||
return render_template('archive.html', title='Archive',
|
return render_template('archive.html', title='Archive',
|
||||||
sections=sections)
|
sections=sections,
|
||||||
|
total_comics=len(COMICS),
|
||||||
|
initial_batch=initial_batch)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/about')
|
@app.route('/about')
|
||||||
@@ -213,10 +296,93 @@ def about():
|
|||||||
return render_template('page.html', title='About', content=html_content)
|
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')
|
@app.route('/api/comics')
|
||||||
def api_comics():
|
def api_comics():
|
||||||
"""API endpoint - returns all comics as JSON"""
|
"""API endpoint - returns all comics as JSON (optionally paginated with sections)"""
|
||||||
return jsonify([enrich_comic(comic) for comic in COMICS])
|
# 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>')
|
@app.route('/api/comics/<int:comic_id>')
|
||||||
@@ -228,6 +394,84 @@ def api_comic(comic_id):
|
|||||||
return jsonify(comic)
|
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)
|
@app.errorhandler(404)
|
||||||
def page_not_found(e):
|
def page_not_found(e):
|
||||||
"""404 error handler"""
|
"""404 error handler"""
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
# Global setting: The name of your comic/website
|
# Global setting: The name of your comic/website
|
||||||
COMIC_NAME = 'Sunday Comics'
|
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.)
|
# Global setting: Your website's domain (used for RSS feed, Open Graph tags, etc.)
|
||||||
# Update this to your production domain when deploying
|
# Update this to your production domain when deploying
|
||||||
SITE_URL = 'http://localhost:3000'
|
SITE_URL = 'http://localhost:3000'
|
||||||
@@ -38,7 +42,12 @@ HEADER_IMAGE = None
|
|||||||
# Global setting: Path to footer image (relative to static/images/)
|
# Global setting: Path to footer image (relative to static/images/)
|
||||||
# Set to None to disable footer image
|
# Set to None to disable footer image
|
||||||
# Example: FOOTER_IMAGE = 'footer.jpg' will use static/images/footer.jpg
|
# 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
|
# Global setting: Set to True to display footer in compact mode
|
||||||
# Compact mode: single line, no border, horizontal layout
|
# Compact mode: single line, no border, horizontal layout
|
||||||
@@ -64,6 +73,13 @@ USE_HEADER_NAV_ICONS = True
|
|||||||
# Uses instagram.png, youtube.png, and mail.png from static/images/icons/
|
# Uses instagram.png, youtube.png, and mail.png from static/images/icons/
|
||||||
USE_FOOTER_SOCIAL_ICONS = True
|
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 media links - set to None to hide the link
|
||||||
SOCIAL_INSTAGRAM = None # e.g., 'https://instagram.com/yourhandle'
|
SOCIAL_INSTAGRAM = None # e.g., 'https://instagram.com/yourhandle'
|
||||||
SOCIAL_YOUTUBE = None # e.g., 'https://youtube.com/@yourchannel'
|
SOCIAL_YOUTUBE = None # e.g., 'https://youtube.com/@yourchannel'
|
||||||
@@ -73,34 +89,25 @@ SOCIAL_EMAIL = None # e.g., 'mailto:your@email.com'
|
|||||||
# Path is relative to static/ directory
|
# Path is relative to static/ directory
|
||||||
API_SPEC_LINK = None # Set to 'openapi.yml' to enable
|
API_SPEC_LINK = None # Set to 'openapi.yml' to enable
|
||||||
|
|
||||||
COMICS = [
|
# Global setting: Set to True to enable comic embed functionality
|
||||||
{
|
# When enabled, users can get embed codes to display comics on other websites
|
||||||
'number': 1,
|
EMBED_ENABLED = True
|
||||||
'title': 'First Comic',
|
|
||||||
'filename': 'comic-001.jpg',
|
# Global setting: Set to True to enable permalink copy button
|
||||||
'mobile_filename': 'comic-001-mobile.jpg', # Optional: mobile version of the comic
|
# When enabled, users can easily copy a direct link to the current comic
|
||||||
'date': '2025-01-01',
|
PERMALINK_ENABLED = True
|
||||||
'alt_text': 'The very first comic',
|
|
||||||
'author_note': 'This is where your comic journey begins!',
|
# Load comics from YAML files
|
||||||
'author_note_md': '2025-01-01.md', # Optional: use markdown from content/author_notes/2025-01-01.md (overrides author_note)
|
from data_loader import load_comics_from_yaml, validate_comics
|
||||||
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT for this comic
|
|
||||||
'plain': True, # Optional: override PLAIN_DEFAULT for this comic
|
COMICS = load_comics_from_yaml('data/comics')
|
||||||
'section': 'Chapter 1: The Beginning', # Optional: start a new section on archive page
|
|
||||||
},
|
# Validate loaded comics
|
||||||
{
|
if not validate_comics(COMICS):
|
||||||
'number': 2,
|
print("Warning: Comic validation failed. Please check your YAML files.")
|
||||||
'filename': 'comic-002.jpg',
|
|
||||||
'date': '2025-01-08',
|
# Show loaded comics count
|
||||||
'alt_text': 'The second comic',
|
if COMICS:
|
||||||
'full_width': True,
|
print(f"Loaded {len(COMICS)} comics from data/comics/")
|
||||||
'plain': True,
|
else:
|
||||||
},
|
print("Warning: No comics loaded! Please add .yaml files to data/comics/")
|
||||||
{
|
|
||||||
'number': 3,
|
|
||||||
'title': 'Third Comic',
|
|
||||||
'filename': 'comic-003.jpg',
|
|
||||||
'date': '2025-01-15',
|
|
||||||
'alt_text': 'The third comic',
|
|
||||||
'author_note': 'Things are getting interesting!',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
93
content/terms.md
Normal file
93
content/terms.md
Normal 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
23
data/comics/001.yaml
Normal 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
9
data/comics/002.yaml
Normal 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
7
data/comics/003.yaml
Normal 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
89
data/comics/README.md
Normal 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
51
data/comics/TEMPLATE.yaml
Normal 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
179
data_loader.py
Normal 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!")
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
Flask==3.0.0
|
Flask==3.0.0
|
||||||
markdown==3.5.1
|
markdown==3.5.1
|
||||||
|
PyYAML==6.0.3
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
# Licensed under the MIT License - see LICENSE file for details
|
# 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 sys
|
||||||
import os
|
import os
|
||||||
@@ -51,7 +51,7 @@ Write your author note here using markdown formatting.
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Add a new comic entry with defaults"""
|
"""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',
|
parser.add_argument('-m', '--markdown', action='store_true',
|
||||||
help='Generate a markdown file for author notes and add author_note_md field to comic entry')
|
help='Generate a markdown file for author notes and add author_note_md field to comic entry')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -59,53 +59,75 @@ def main():
|
|||||||
# Get next number
|
# Get next number
|
||||||
number = max(comic['number'] for comic in COMICS) + 1 if COMICS else 1
|
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
|
# Create entry with defaults
|
||||||
comic = {
|
comic_data = {
|
||||||
'number': number,
|
'number': number,
|
||||||
'filename': f'comic-{number:03d}.png',
|
'filename': f'comic-{number:03d}.png',
|
||||||
'date': datetime.now().strftime('%Y-%m-%d'),
|
'date': date_str,
|
||||||
'alt_text': f'Comic #{number}',
|
'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__))
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
parent_dir = os.path.dirname(script_dir)
|
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
|
# Create comics directory if it doesn't exist
|
||||||
with open(comics_file, 'r') as f:
|
os.makedirs(comics_dir, exist_ok=True)
|
||||||
content = f.read()
|
|
||||||
|
# Check if file already exists
|
||||||
|
if os.path.exists(yaml_file):
|
||||||
|
print(f"Error: Comic file already exists: {yaml_file}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 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']}"
|
||||||
|
"""
|
||||||
|
|
||||||
# Format new entry
|
|
||||||
if args.markdown:
|
if args.markdown:
|
||||||
entry_str = f""" {{
|
yaml_content += f'\n# Markdown author note (overrides author_note if present)\nauthor_note_md: "{date_str}.md"\n'
|
||||||
'number': {comic['number']},
|
|
||||||
'filename': {repr(comic['filename'])},
|
|
||||||
'date': {repr(comic['date'])},
|
|
||||||
'alt_text': {repr(comic['alt_text'])},
|
|
||||||
'author_note_md': {repr(comic['date'] + '.md')}
|
|
||||||
}}"""
|
|
||||||
else:
|
else:
|
||||||
entry_str = f""" {{
|
yaml_content += '\n# Optional: Add author note\n# author_note: "Your thoughts about this comic."\n'
|
||||||
'number': {comic['number']},
|
|
||||||
'filename': {repr(comic['filename'])},
|
|
||||||
'date': {repr(comic['date'])},
|
|
||||||
'alt_text': {repr(comic['alt_text'])}
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
# Insert before closing bracket
|
yaml_content += """
|
||||||
insert_pos = content.rfind(']')
|
# Optional: Add a title
|
||||||
new_content = content[:insert_pos] + entry_str + ",\n" + content[insert_pos:]
|
# title: "Title of Your Comic"
|
||||||
|
|
||||||
# Write back
|
# Optional: Override global settings
|
||||||
with open(comics_file, 'w') as f:
|
# full_width: true
|
||||||
f.write(new_content)
|
# plain: true
|
||||||
|
|
||||||
print(f"Added comic #{number}")
|
# 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
|
# Create markdown file if requested
|
||||||
if args.markdown:
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
154
scripts/bump_version.py
Executable file
154
scripts/bump_version.py
Executable file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Sunday Comics - Version bump script
|
||||||
|
# Copyright (c) 2025 Tomasita Cabrera
|
||||||
|
# Licensed under the MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
"""
|
||||||
|
Script to bump the project version number
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/bump_version.py # Use today's date
|
||||||
|
python scripts/bump_version.py 2025.12.25 # Use specific date
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
def validate_version(version_str):
|
||||||
|
"""Validate version format (YYYY.MM.DD)"""
|
||||||
|
pattern = r'^\d{4}\.\d{2}\.\d{2}$'
|
||||||
|
if not re.match(pattern, version_str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Try to parse as a date to ensure it's valid
|
||||||
|
try:
|
||||||
|
parts = version_str.split('.')
|
||||||
|
year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
|
||||||
|
datetime(year, month, day)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_version(parent_dir):
|
||||||
|
"""Read current version from version.py"""
|
||||||
|
version_file = os.path.join(parent_dir, 'version.py')
|
||||||
|
try:
|
||||||
|
with open(version_file, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_version_py(parent_dir, new_version):
|
||||||
|
"""Update version.py with new version"""
|
||||||
|
version_file = os.path.join(parent_dir, 'version.py')
|
||||||
|
|
||||||
|
content = f"""# Sunday Comics Version
|
||||||
|
# This file contains the version number for the project
|
||||||
|
# Format: YYYY.MM.DD (date-based versioning)
|
||||||
|
|
||||||
|
__version__ = "{new_version}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open(version_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print(f"✓ Updated {version_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def update_version_file(parent_dir, new_version):
|
||||||
|
"""Update VERSION file with new version"""
|
||||||
|
version_file = os.path.join(parent_dir, 'VERSION')
|
||||||
|
|
||||||
|
with open(version_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(f"{new_version}\n")
|
||||||
|
|
||||||
|
print(f"✓ Updated {version_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def remind_changelog(parent_dir, new_version, current_version):
|
||||||
|
"""Remind user to update CHANGELOG.md"""
|
||||||
|
changelog_file = os.path.join(parent_dir, 'CHANGELOG.md')
|
||||||
|
|
||||||
|
print(f"\n📝 Don't forget to update {changelog_file}!")
|
||||||
|
print(f"\nAdd your changes under the new version section:")
|
||||||
|
print(f"\n## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}")
|
||||||
|
print(f"\n### Added")
|
||||||
|
print(f"### Changed")
|
||||||
|
print(f"### Fixed")
|
||||||
|
|
||||||
|
if os.path.exists(changelog_file):
|
||||||
|
print(f"\n💡 Tip: Edit the file now with: nano {changelog_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Bump project version number',
|
||||||
|
epilog='Examples:\n %(prog)s\n %(prog)s 2025.12.25',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'version',
|
||||||
|
nargs='?',
|
||||||
|
help='Version number in YYYY.MM.DD format (defaults to today\'s date)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--no-changelog-reminder',
|
||||||
|
action='store_true',
|
||||||
|
help='Skip the changelog reminder'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Determine new version
|
||||||
|
if args.version:
|
||||||
|
new_version = args.version
|
||||||
|
if not validate_version(new_version):
|
||||||
|
print(f"Error: Invalid version format '{new_version}'")
|
||||||
|
print(f"Expected format: YYYY.MM.DD (e.g., 2025.12.25)")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
# Use today's date
|
||||||
|
new_version = datetime.now().strftime('%Y.%m.%d')
|
||||||
|
|
||||||
|
# Get parent directory (project root)
|
||||||
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# Get current version
|
||||||
|
current_version = get_current_version(parent_dir)
|
||||||
|
|
||||||
|
if current_version:
|
||||||
|
print(f"Current version: {current_version}")
|
||||||
|
|
||||||
|
print(f"New version: {new_version}")
|
||||||
|
|
||||||
|
# Check if version is the same
|
||||||
|
if current_version == new_version:
|
||||||
|
print(f"\n⚠️ Version is already {new_version}")
|
||||||
|
response = input("Continue anyway? [y/N]: ").lower().strip()
|
||||||
|
if response != 'y':
|
||||||
|
print("Aborted.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Update files
|
||||||
|
print(f"\nUpdating version files...")
|
||||||
|
update_version_py(parent_dir, new_version)
|
||||||
|
update_version_file(parent_dir, new_version)
|
||||||
|
|
||||||
|
print(f"\n✅ Version bumped to {new_version}")
|
||||||
|
|
||||||
|
# Remind about changelog
|
||||||
|
if not args.no_changelog_reminder:
|
||||||
|
remind_changelog(parent_dir, new_version, current_version)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
88
scripts/generate_sitemap.py
Normal file
88
scripts/generate_sitemap.py
Normal 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
84
scripts/publish_comic.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Sunday Comics - Publish script
|
||||||
|
# Copyright (c) 2025 Tomasita Cabrera
|
||||||
|
# Licensed under the MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
"""
|
||||||
|
Convenience script to rebuild cache and regenerate all static files.
|
||||||
|
Run this after adding or updating comics.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Add parent directory to path so we can import data_loader
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from data_loader import load_comics_from_yaml, clear_cache
|
||||||
|
|
||||||
|
|
||||||
|
def run_script(script_name, description):
|
||||||
|
"""Run a script and handle errors"""
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
script_path = os.path.join(script_dir, script_name)
|
||||||
|
|
||||||
|
print(f"{description}...")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, script_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Print only the summary line (last non-empty line)
|
||||||
|
output_lines = [line for line in result.stdout.strip().split('\n') if line.strip()]
|
||||||
|
if output_lines:
|
||||||
|
print(f" ✓ {output_lines[-1]}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ Failed!")
|
||||||
|
if result.stderr:
|
||||||
|
print(f" Error: {result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Rebuild cache and regenerate all static files"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Publishing Comics")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 1: Rebuild cache
|
||||||
|
print("1. Rebuilding comics cache...")
|
||||||
|
clear_cache()
|
||||||
|
# Load with cache enabled - since we just cleared it, this will reload from YAML
|
||||||
|
# and automatically save the cache
|
||||||
|
comics = load_comics_from_yaml(use_cache=True)
|
||||||
|
|
||||||
|
if not comics:
|
||||||
|
print(" ✗ No comics found!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f" ✓ Cached {len(comics)} comics")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 2: Generate RSS feed
|
||||||
|
success = run_script('generate_rss.py', '2. Generating RSS feed')
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Step 3: Generate sitemap
|
||||||
|
success = run_script('generate_sitemap.py', '3. Generating sitemap')
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("✓ All static files updated successfully!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
38
scripts/rebuild_cache.py
Normal file
38
scripts/rebuild_cache.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Sunday Comics - Cache rebuild script
|
||||||
|
# Copyright (c) 2025 Tomasita Cabrera
|
||||||
|
# Licensed under the MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
"""
|
||||||
|
Script to rebuild the comics cache from YAML files.
|
||||||
|
Useful for forcing a fresh cache build.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add parent directory to path so we can import data_loader
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
from data_loader import load_comics_from_yaml, clear_cache
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Rebuild the comics cache"""
|
||||||
|
print("Clearing existing cache...")
|
||||||
|
clear_cache()
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("Rebuilding cache from YAML files...")
|
||||||
|
# Load with cache enabled - since we just cleared it, this will reload from YAML
|
||||||
|
# and automatically save the cache
|
||||||
|
comics = load_comics_from_yaml(use_cache=True)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if comics:
|
||||||
|
print(f"✓ Cache rebuilt successfully with {len(comics)} comics")
|
||||||
|
else:
|
||||||
|
print("✗ No comics found to cache")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -65,6 +65,70 @@ body {
|
|||||||
background-color: var(--color-background);
|
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 {
|
.container {
|
||||||
max-width: var(--container-max-width);
|
max-width: var(--container-max-width);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -350,6 +414,11 @@ main {
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Remove outline when comic image container is focused programmatically (for keyboard nav) */
|
||||||
|
.comic-image:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.comic-image a {
|
.comic-image a {
|
||||||
display: block;
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -362,6 +431,30 @@ main {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-image comics (webtoon style) */
|
||||||
|
.comic-image-multi {
|
||||||
|
padding: 0; /* Remove padding for seamless stacking */
|
||||||
|
}
|
||||||
|
|
||||||
|
.comic-image-multi img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
vertical-align: bottom; /* Removes tiny gap below images */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lazy-loaded images - placeholder while loading */
|
||||||
|
.comic-image-multi img.lazy-load {
|
||||||
|
min-height: 200px;
|
||||||
|
background: var(--color-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comic-image-multi img.lazy-load.loaded {
|
||||||
|
min-height: auto;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Comic Navigation */
|
/* Comic Navigation */
|
||||||
.comic-navigation {
|
.comic-navigation {
|
||||||
border-top: var(--border-width-thin) solid var(--border-color);
|
border-top: var(--border-width-thin) solid var(--border-color);
|
||||||
@@ -508,6 +601,14 @@ main {
|
|||||||
display: block;
|
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 {
|
.archive-item img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--archive-thumbnail-height);
|
height: var(--archive-thumbnail-height);
|
||||||
@@ -639,6 +740,7 @@ main {
|
|||||||
|
|
||||||
/* Compact footer mobile adjustments */
|
/* Compact footer mobile adjustments */
|
||||||
footer.compact-footer .container {
|
footer.compact-footer .container {
|
||||||
|
max-width: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -667,6 +769,27 @@ main {
|
|||||||
footer.compact-footer .footer-bottom::before {
|
footer.compact-footer .footer-bottom::before {
|
||||||
display: none;
|
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 */
|
/* Footer */
|
||||||
@@ -768,10 +891,76 @@ footer {
|
|||||||
color: var(--color-text-muted);
|
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 {
|
.footer-bottom {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-md);
|
||||||
padding-top: var(--space-lg);
|
padding-top: var(--space-lg);
|
||||||
border-top: var(--border-width-thin) solid var(--border-color);
|
border-top: var(--border-width-thin) solid var(--border-color);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-bottom p {
|
.footer-bottom p {
|
||||||
@@ -782,6 +971,11 @@ footer {
|
|||||||
letter-spacing: var(--letter-spacing-tight);
|
letter-spacing: var(--letter-spacing-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-divider {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
.footer-bottom a.api-link {
|
.footer-bottom a.api-link {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -794,6 +988,18 @@ footer {
|
|||||||
text-decoration: underline;
|
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 */
|
/* Compact Footer Mode */
|
||||||
footer.compact-footer {
|
footer.compact-footer {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
@@ -802,6 +1008,7 @@ footer.compact-footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer.compact-footer .container {
|
footer.compact-footer .container {
|
||||||
|
max-width: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -885,3 +1092,236 @@ footer.compact-footer .footer-section:not(:last-child)::after {
|
|||||||
margin-left: var(--space-md);
|
margin-left: var(--space-md);
|
||||||
color: var(--color-text-muted);
|
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
BIN
static/images/banner.jpg
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/embed.png
LFS
Normal file
BIN
static/images/icons/embed.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/link.png
LFS
Normal file
BIN
static/images/icons/link.png
LFS
Normal file
Binary file not shown.
BIN
static/images/sunday.jpg
LFS
Normal file
BIN
static/images/sunday.jpg
LFS
Normal file
Binary file not shown.
221
static/js/archive-lazy-load.js
Normal file
221
static/js/archive-lazy-load.js
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Sunday Comics - Archive Lazy Loading
|
||||||
|
* Implements infinite scroll for the archive page
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
let isLoading = false;
|
||||||
|
let hasMore = true;
|
||||||
|
const perPage = 24;
|
||||||
|
|
||||||
|
// Get elements
|
||||||
|
const archiveContent = document.querySelector('.archive-content');
|
||||||
|
if (!archiveContent) return; // Not on archive page
|
||||||
|
|
||||||
|
const totalComics = parseInt(archiveContent.dataset.totalComics || '0');
|
||||||
|
const initialBatch = parseInt(archiveContent.dataset.initialBatch || '24');
|
||||||
|
|
||||||
|
// Calculate if there are more comics to load
|
||||||
|
hasMore = totalComics > initialBatch;
|
||||||
|
|
||||||
|
// Create loading indicator
|
||||||
|
const loadingIndicator = document.createElement('div');
|
||||||
|
loadingIndicator.className = 'archive-loading';
|
||||||
|
loadingIndicator.innerHTML = '<p>Loading more comics...</p>';
|
||||||
|
loadingIndicator.style.display = 'none';
|
||||||
|
loadingIndicator.style.textAlign = 'center';
|
||||||
|
loadingIndicator.style.padding = '2rem';
|
||||||
|
archiveContent.parentNode.insertBefore(loadingIndicator, archiveContent.nextSibling);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more comics from the API
|
||||||
|
*/
|
||||||
|
async function loadMoreComics() {
|
||||||
|
if (isLoading || !hasMore) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
loadingIndicator.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentPage++;
|
||||||
|
const response = await fetch(`/api/comics?page=${currentPage}&per_page=${perPage}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Add new comics to the DOM
|
||||||
|
appendComics(data.sections);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
hasMore = data.has_more;
|
||||||
|
|
||||||
|
if (!hasMore) {
|
||||||
|
loadingIndicator.innerHTML = '<p>End of archive</p>';
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingIndicator.style.display = 'none';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading more comics:', error);
|
||||||
|
loadingIndicator.innerHTML = '<p>Error loading comics. Please try again.</p>';
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingIndicator.style.display = 'none';
|
||||||
|
isLoading = false;
|
||||||
|
}, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
loadingIndicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append comics to the archive
|
||||||
|
* @param {Array} sections - Array of section objects with title and comics
|
||||||
|
*/
|
||||||
|
function appendComics(sections) {
|
||||||
|
const archiveFullWidth = document.querySelector('.archive-content-fullwidth') !== null;
|
||||||
|
const sectionsEnabled = document.querySelector('.section-header') !== null;
|
||||||
|
|
||||||
|
sections.forEach(section => {
|
||||||
|
const sectionTitle = section.section_title;
|
||||||
|
const comics = section.comics;
|
||||||
|
|
||||||
|
// Check if we need to create a new section or append to existing
|
||||||
|
let targetGrid;
|
||||||
|
|
||||||
|
if (sectionsEnabled && sectionTitle) {
|
||||||
|
// Check if section already exists
|
||||||
|
const existingSection = findSectionByTitle(sectionTitle);
|
||||||
|
|
||||||
|
if (existingSection) {
|
||||||
|
// Append to existing section grid
|
||||||
|
targetGrid = existingSection.querySelector('.archive-grid');
|
||||||
|
} else {
|
||||||
|
// Create new section
|
||||||
|
const sectionHeader = document.createElement('div');
|
||||||
|
sectionHeader.className = 'section-header';
|
||||||
|
sectionHeader.innerHTML = `<h2>${sectionTitle}</h2>`;
|
||||||
|
archiveContent.appendChild(sectionHeader);
|
||||||
|
|
||||||
|
targetGrid = document.createElement('div');
|
||||||
|
targetGrid.className = 'archive-grid' + (archiveFullWidth ? ' archive-grid-fullwidth' : '');
|
||||||
|
archiveContent.appendChild(targetGrid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No sections or no title - use the last grid or create one
|
||||||
|
targetGrid = archiveContent.querySelector('.archive-grid:last-of-type');
|
||||||
|
|
||||||
|
if (!targetGrid) {
|
||||||
|
targetGrid = document.createElement('div');
|
||||||
|
targetGrid.className = 'archive-grid' + (archiveFullWidth ? ' archive-grid-fullwidth' : '');
|
||||||
|
archiveContent.appendChild(targetGrid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add each comic to the grid
|
||||||
|
comics.forEach(comic => {
|
||||||
|
const item = createArchiveItem(comic, archiveFullWidth);
|
||||||
|
targetGrid.appendChild(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing section by title
|
||||||
|
* @param {string} title - Section title to find
|
||||||
|
* @returns {Element|null} - The section element or null
|
||||||
|
*/
|
||||||
|
function findSectionByTitle(title) {
|
||||||
|
const sectionHeaders = archiveContent.querySelectorAll('.section-header h2');
|
||||||
|
for (const header of sectionHeaders) {
|
||||||
|
if (header.textContent.trim() === title) {
|
||||||
|
// Return the grid following this header
|
||||||
|
let nextEl = header.parentElement.nextElementSibling;
|
||||||
|
while (nextEl && !nextEl.classList.contains('archive-grid')) {
|
||||||
|
nextEl = nextEl.nextElementSibling;
|
||||||
|
}
|
||||||
|
return nextEl ? nextEl.parentElement : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an archive item element
|
||||||
|
* @param {Object} comic - Comic data
|
||||||
|
* @param {boolean} fullWidth - Whether using full width layout
|
||||||
|
* @returns {Element} - The archive item element
|
||||||
|
*/
|
||||||
|
function createArchiveItem(comic, fullWidth) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'archive-item' + (fullWidth ? ' archive-item-fullwidth' : '');
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `/comic/${comic.number}`;
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = `/static/images/thumbs/${comic.filename}`;
|
||||||
|
img.alt = comic.title || `#${comic.number}`;
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.onerror = function() {
|
||||||
|
this.onerror = null;
|
||||||
|
this.src = '/static/images/thumbs/default.jpg';
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'archive-info';
|
||||||
|
|
||||||
|
if (!fullWidth) {
|
||||||
|
const title = document.createElement('h3');
|
||||||
|
title.textContent = `#${comic.number}${comic.title ? ': ' + comic.title : ''}`;
|
||||||
|
info.appendChild(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = document.createElement('p');
|
||||||
|
date.className = 'archive-date';
|
||||||
|
date.textContent = comic.date;
|
||||||
|
info.appendChild(date);
|
||||||
|
|
||||||
|
link.appendChild(img);
|
||||||
|
link.appendChild(info);
|
||||||
|
item.appendChild(link);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has scrolled near the bottom
|
||||||
|
*/
|
||||||
|
function checkScrollPosition() {
|
||||||
|
if (isLoading || !hasMore) return;
|
||||||
|
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||||
|
const windowHeight = window.innerHeight;
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
|
||||||
|
// Trigger when user is within 1000px of the bottom
|
||||||
|
if (scrollTop + windowHeight >= documentHeight - 1000) {
|
||||||
|
loadMoreComics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up scroll listener
|
||||||
|
let scrollTimeout;
|
||||||
|
window.addEventListener('scroll', function() {
|
||||||
|
if (scrollTimeout) {
|
||||||
|
clearTimeout(scrollTimeout);
|
||||||
|
}
|
||||||
|
scrollTimeout = setTimeout(checkScrollPosition, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check initial scroll position (in case page is short)
|
||||||
|
setTimeout(checkScrollPosition, 500);
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
let totalComics = 0;
|
let totalComics = 0;
|
||||||
let comicName = ''; // Will be extracted from initial page title
|
let comicName = ''; // Will be extracted from initial page title
|
||||||
let currentComicNumber = 0;
|
let currentComicNumber = 0;
|
||||||
|
let lazyLoadObserver = null;
|
||||||
|
|
||||||
// Fetch and display a comic
|
// Fetch and display a comic
|
||||||
async function loadComic(comicId) {
|
async function loadComic(comicId) {
|
||||||
@@ -32,6 +33,12 @@
|
|||||||
const title = comic.title || `#${comic.number}`;
|
const title = comic.title || `#${comic.number}`;
|
||||||
currentComicNumber = 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
|
// Update container class for full-width option
|
||||||
const container = document.querySelector('.comic-container');
|
const container = document.querySelector('.comic-container');
|
||||||
if (comic.full_width) {
|
if (comic.full_width) {
|
||||||
@@ -73,8 +80,15 @@
|
|||||||
const comicImageDiv = document.querySelector('.comic-image');
|
const comicImageDiv = document.querySelector('.comic-image');
|
||||||
updateComicImage(comicImageDiv, comic, title);
|
updateComicImage(comicImageDiv, comic, title);
|
||||||
|
|
||||||
// Update or create/remove the link wrapper
|
// Update or create/remove the link wrapper (only for single-image comics)
|
||||||
updateComicImageLink(comic.number);
|
if (!comic.is_multi_image) {
|
||||||
|
updateComicImageLink(comic.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize lazy loading for multi-image comics
|
||||||
|
if (comic.is_multi_image) {
|
||||||
|
initLazyLoad();
|
||||||
|
}
|
||||||
|
|
||||||
// Update author note
|
// Update author note
|
||||||
let transcriptDiv = document.querySelector('.comic-transcript');
|
let transcriptDiv = document.querySelector('.comic-transcript');
|
||||||
@@ -118,6 +132,48 @@
|
|||||||
|
|
||||||
// Update URL without reload
|
// Update URL without reload
|
||||||
history.pushState({ comicId: comic.number }, '', `/comic/${comic.number}`);
|
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
|
// Update or create comic image with optional mobile version
|
||||||
@@ -125,30 +181,60 @@
|
|||||||
// Clear all existing content
|
// Clear all existing content
|
||||||
comicImageDiv.innerHTML = '';
|
comicImageDiv.innerHTML = '';
|
||||||
|
|
||||||
// Create new image element(s)
|
// Update container class for multi-image
|
||||||
if (comic.mobile_filename) {
|
if (comic.is_multi_image) {
|
||||||
// Create picture element with mobile source
|
comicImageDiv.classList.add('comic-image-multi');
|
||||||
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 {
|
} else {
|
||||||
// Create regular img element
|
comicImageDiv.classList.remove('comic-image-multi');
|
||||||
const img = document.createElement('img');
|
}
|
||||||
img.src = `/static/images/comics/${comic.filename}`;
|
|
||||||
img.alt = title;
|
// Create new image element(s)
|
||||||
img.title = comic.alt_text;
|
if (comic.is_multi_image) {
|
||||||
comicImageDiv.appendChild(img);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +253,7 @@
|
|||||||
if (currentNumber < totalComics) {
|
if (currentNumber < totalComics) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = `/comic/${currentNumber + 1}`;
|
link.href = `/comic/${currentNumber + 1}`;
|
||||||
|
link.setAttribute('aria-label', 'Click to view next comic');
|
||||||
link.onclick = (e) => {
|
link.onclick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
loadComic(currentNumber + 1);
|
loadComic(currentNumber + 1);
|
||||||
@@ -188,9 +275,23 @@
|
|||||||
if (currentNumber > 1) {
|
if (currentNumber > 1) {
|
||||||
firstBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
firstBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||||
firstBtn.onclick = (e) => { e.preventDefault(); loadComic(1); };
|
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 {
|
} else {
|
||||||
firstBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
firstBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||||
firstBtn.onclick = null;
|
firstBtn.onclick = null;
|
||||||
|
firstBtn.onkeydown = null;
|
||||||
|
firstBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
firstBtn.removeAttribute('tabindex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Previous button
|
// Previous button
|
||||||
@@ -198,9 +299,23 @@
|
|||||||
if (currentNumber > 1) {
|
if (currentNumber > 1) {
|
||||||
prevBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
prevBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||||
prevBtn.onclick = (e) => { e.preventDefault(); loadComic(currentNumber - 1); };
|
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 {
|
} else {
|
||||||
prevBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
prevBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||||
prevBtn.onclick = null;
|
prevBtn.onclick = null;
|
||||||
|
prevBtn.onkeydown = null;
|
||||||
|
prevBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
prevBtn.removeAttribute('tabindex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comic date display
|
// Comic date display
|
||||||
@@ -213,9 +328,23 @@
|
|||||||
if (currentNumber < totalComics) {
|
if (currentNumber < totalComics) {
|
||||||
nextBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
nextBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||||
nextBtn.onclick = (e) => { e.preventDefault(); loadComic(currentNumber + 1); };
|
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 {
|
} else {
|
||||||
nextBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
nextBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||||
nextBtn.onclick = null;
|
nextBtn.onclick = null;
|
||||||
|
nextBtn.onkeydown = null;
|
||||||
|
nextBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
nextBtn.removeAttribute('tabindex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest button
|
// Latest button
|
||||||
@@ -223,9 +352,23 @@
|
|||||||
if (currentNumber < totalComics) {
|
if (currentNumber < totalComics) {
|
||||||
latestBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
latestBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||||
latestBtn.onclick = (e) => { e.preventDefault(); loadComic(totalComics); };
|
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 {
|
} else {
|
||||||
latestBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
latestBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||||
latestBtn.onclick = null;
|
latestBtn.onclick = null;
|
||||||
|
latestBtn.onkeydown = null;
|
||||||
|
latestBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
latestBtn.removeAttribute('tabindex');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,12 +379,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const announcer = document.getElementById('comic-announcer');
|
||||||
|
|
||||||
switch(event.key) {
|
switch(event.key) {
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
// Previous comic
|
// Previous comic
|
||||||
if (currentComicNumber > 1) {
|
if (currentComicNumber > 1) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loadComic(currentComicNumber - 1);
|
loadComic(currentComicNumber - 1);
|
||||||
|
} else if (announcer) {
|
||||||
|
announcer.textContent = 'Already at the first comic';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
@@ -249,6 +396,8 @@
|
|||||||
if (currentComicNumber < totalComics) {
|
if (currentComicNumber < totalComics) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loadComic(currentComicNumber + 1);
|
loadComic(currentComicNumber + 1);
|
||||||
|
} else if (announcer) {
|
||||||
|
announcer.textContent = 'Already at the latest comic';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
@@ -256,6 +405,8 @@
|
|||||||
if (currentComicNumber > 1) {
|
if (currentComicNumber > 1) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loadComic(1);
|
loadComic(1);
|
||||||
|
} else if (announcer) {
|
||||||
|
announcer.textContent = 'Already at the first comic';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
@@ -263,6 +414,8 @@
|
|||||||
if (currentComicNumber < totalComics) {
|
if (currentComicNumber < totalComics) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loadComic(totalComics);
|
loadComic(totalComics);
|
||||||
|
} else if (announcer) {
|
||||||
|
announcer.textContent = 'Already at the latest comic';
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -293,7 +446,17 @@
|
|||||||
const dateDisplay = document.querySelector('.comic-date-display');
|
const dateDisplay = document.querySelector('.comic-date-display');
|
||||||
const formattedDate = dateDisplay ? dateDisplay.textContent : null;
|
const formattedDate = dateDisplay ? dateDisplay.textContent : null;
|
||||||
updateNavButtons(currentNumber, formattedDate);
|
updateNavButtons(currentNumber, formattedDate);
|
||||||
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
|
// Handle browser back/forward
|
||||||
|
|||||||
129
static/js/embed.js
Normal file
129
static/js/embed.js
Normal 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
84
static/js/permalink.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -16,39 +16,107 @@ paths:
|
|||||||
/api/comics:
|
/api/comics:
|
||||||
get:
|
get:
|
||||||
summary: Get all comics
|
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
|
operationId: getAllComics
|
||||||
tags:
|
tags:
|
||||||
- Comics
|
- 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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Successful response with array of comics
|
description: Successful response
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
oneOf:
|
||||||
items:
|
- type: array
|
||||||
$ref: '#/components/schemas/Comic'
|
description: Simple array response (when no pagination parameters provided)
|
||||||
example:
|
items:
|
||||||
- number: 1
|
$ref: '#/components/schemas/Comic'
|
||||||
title: "First Comic"
|
- $ref: '#/components/schemas/PaginatedComicsResponse'
|
||||||
filename: "comic-001.jpg"
|
examples:
|
||||||
mobile_filename: "comic-001-mobile.jpg"
|
simpleArray:
|
||||||
date: "2025-01-01"
|
summary: Simple array response (default)
|
||||||
alt_text: "The very first comic"
|
value:
|
||||||
author_note: "This is where your comic journey begins!"
|
- number: 1
|
||||||
full_width: true
|
title: "First Comic"
|
||||||
plain: true
|
filename: "comic-001.jpg"
|
||||||
formatted_date: "Wednesday, January 1, 2025"
|
mobile_filename: "comic-001-mobile.jpg"
|
||||||
author_note_is_html: false
|
date: "2025-01-01"
|
||||||
- number: 2
|
alt_text: "The very first comic"
|
||||||
filename: "comic-002.jpg"
|
author_note: "This is where your comic journey begins!"
|
||||||
date: "2025-01-08"
|
full_width: true
|
||||||
alt_text: "The second comic"
|
plain: true
|
||||||
full_width: true
|
formatted_date: "Wednesday, January 1, 2025"
|
||||||
plain: true
|
author_note_is_html: false
|
||||||
formatted_date: "Wednesday, January 8, 2025"
|
- number: 2
|
||||||
author_note_is_html: false
|
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}:
|
/api/comics/{comic_id}:
|
||||||
get:
|
get:
|
||||||
@@ -107,6 +175,9 @@ components:
|
|||||||
- plain
|
- plain
|
||||||
- formatted_date
|
- formatted_date
|
||||||
- author_note_is_html
|
- author_note_is_html
|
||||||
|
- filenames
|
||||||
|
- alt_texts
|
||||||
|
- is_multi_image
|
||||||
properties:
|
properties:
|
||||||
number:
|
number:
|
||||||
type: integer
|
type: integer
|
||||||
@@ -118,9 +189,20 @@ components:
|
|||||||
description: Comic title (optional, defaults to "#X" if not provided)
|
description: Comic title (optional, defaults to "#X" if not provided)
|
||||||
example: "First Comic"
|
example: "First Comic"
|
||||||
filename:
|
filename:
|
||||||
type: string
|
oneOf:
|
||||||
description: Image filename in static/images/comics/
|
- 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"
|
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:
|
mobile_filename:
|
||||||
type: string
|
type: string
|
||||||
description: Optional mobile version of the comic image
|
description: Optional mobile version of the comic image
|
||||||
@@ -131,13 +213,36 @@ components:
|
|||||||
description: Publication date in YYYY-MM-DD format
|
description: Publication date in YYYY-MM-DD format
|
||||||
example: "2025-01-01"
|
example: "2025-01-01"
|
||||||
alt_text:
|
alt_text:
|
||||||
type: string
|
oneOf:
|
||||||
description: Accessibility text for the comic image
|
- 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"
|
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:
|
author_note:
|
||||||
type: string
|
type: string
|
||||||
description: Author's note about the comic (plain text or HTML from markdown)
|
description: Author's note about the comic (plain text or HTML from markdown)
|
||||||
example: "This is where your comic journey begins!"
|
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:
|
full_width:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the comic should display in full-width mode (computed from global default and per-comic override)
|
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)
|
description: Indicates whether author_note contains HTML (from markdown) or plain text (computed field)
|
||||||
example: false
|
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:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
|
|
||||||
<div class="page-header{% if archive_full_width %} page-header-fullwidth{% endif %}">
|
<div class="page-header{% if archive_full_width %} page-header-fullwidth{% endif %}">
|
||||||
<h1>Comic Archive</h1>
|
<h1>Comic Archive</h1>
|
||||||
<p>Browse all {% set total = namespace(count=0) %}{% for section_title, section_comics in sections %}{% set total.count = total.count + section_comics|length %}{% endfor %}{{ total.count }} comics</p>
|
<p>Browse all {{ total_comics }} comics</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}">
|
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}"
|
||||||
|
data-total-comics="{{ total_comics }}"
|
||||||
|
data-initial-batch="{{ initial_batch }}">
|
||||||
{% for section_title, section_comics in sections %}
|
{% for section_title, section_comics in sections %}
|
||||||
{% if section_title and sections_enabled %}
|
{% if section_title and sections_enabled %}
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -24,7 +26,8 @@
|
|||||||
<a href="{{ url_for('comic', comic_id=comic.number) }}">
|
<a href="{{ url_for('comic', comic_id=comic.number) }}">
|
||||||
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
|
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
|
||||||
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
|
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
|
||||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}">
|
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||||
|
loading="lazy">
|
||||||
<div class="archive-info">
|
<div class="archive-info">
|
||||||
{% if not archive_full_width %}
|
{% if not archive_full_width %}
|
||||||
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
|
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
|
||||||
@@ -42,3 +45,7 @@
|
|||||||
<div class="container"> {# Reopen container for footer #}
|
<div class="container"> {# Reopen container for footer #}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{{ url_for('static', filename='js/archive-lazy-load.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
<!-- SEO Meta Tags -->
|
<!-- SEO Meta Tags -->
|
||||||
<meta name="description" content="{% block meta_description %}A webcomic about life, the universe, and everything{% endblock %}">
|
<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 -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website">
|
<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') }}">
|
<link rel="alternate" type="application/rss+xml" title="{{ comic_name }} RSS Feed" href="{{ url_for('static', filename='feed.rss') }}">
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</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 %}
|
{% if header_image %}
|
||||||
<div class="site-header-image">
|
<div class="site-header-image">
|
||||||
<img src="{{ url_for('static', filename='images/' + header_image) }}" alt="{{ comic_name }} Header">
|
<img src="{{ url_for('static', filename='images/' + header_image) }}" alt="{{ comic_name }} Header">
|
||||||
@@ -59,17 +70,17 @@
|
|||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}>
|
<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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('archive') }}" {% if request.endpoint == 'archive' %}class="active"{% endif %}>
|
<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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ url_for('about') }}" {% if request.endpoint == 'about' %}class="active"{% endif %}>
|
<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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -77,7 +88,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main id="main-content">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +103,7 @@
|
|||||||
{% if social_instagram %}
|
{% if social_instagram %}
|
||||||
<a href="{{ social_instagram }}" target="_blank" rel="noopener noreferrer" aria-label="Instagram">
|
<a href="{{ social_instagram }}" target="_blank" rel="noopener noreferrer" aria-label="Instagram">
|
||||||
{% if use_footer_social_icons %}
|
{% 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 %}
|
{% else %}
|
||||||
Instagram
|
Instagram
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -101,7 +112,7 @@
|
|||||||
{% if social_youtube %}
|
{% if social_youtube %}
|
||||||
<a href="{{ social_youtube }}" target="_blank" rel="noopener noreferrer" aria-label="YouTube">
|
<a href="{{ social_youtube }}" target="_blank" rel="noopener noreferrer" aria-label="YouTube">
|
||||||
{% if use_footer_social_icons %}
|
{% 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 %}
|
{% else %}
|
||||||
YouTube
|
YouTube
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -110,7 +121,7 @@
|
|||||||
{% if social_email %}
|
{% if social_email %}
|
||||||
<a href="{{ social_email }}" aria-label="Email">
|
<a href="{{ social_email }}" aria-label="Email">
|
||||||
{% if use_footer_social_icons %}
|
{% 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 %}
|
{% else %}
|
||||||
Email
|
Email
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -118,7 +129,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ url_for('static', filename='feed.rss') }}" aria-label="RSS Feed">
|
<a href="{{ url_for('static', filename='feed.rss') }}" aria-label="RSS Feed">
|
||||||
{% if use_footer_social_icons %}
|
{% 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 %}
|
{% else %}
|
||||||
RSS Feed
|
RSS Feed
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -129,6 +140,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if newsletter_enabled %}
|
||||||
<div class="footer-section">
|
<div class="footer-section">
|
||||||
<h3>Newsletter</h3>
|
<h3>Newsletter</h3>
|
||||||
<!-- Replace with your newsletter service form -->
|
<!-- Replace with your newsletter service form -->
|
||||||
@@ -138,10 +150,34 @@
|
|||||||
</form> -->
|
</form> -->
|
||||||
<p class="newsletter-placeholder">Newsletter coming soon!</p>
|
<p class="newsletter-placeholder">Newsletter coming soon!</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>© 2025 {{ comic_name }}. All rights reserved.</p>
|
<p>© {{ 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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -152,7 +188,31 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">×</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>
|
<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 %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -4,7 +4,31 @@
|
|||||||
|
|
||||||
{% block og_image %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endblock %}
|
{% 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 %}
|
{% 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 }}">
|
<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 %}
|
{% if not comic.plain %}
|
||||||
<div class="comic-header">
|
<div class="comic-header">
|
||||||
@@ -13,34 +37,48 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="comic-image">
|
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
|
||||||
{% if comic.number < total_comics %}
|
{% if comic.is_multi_image %}
|
||||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}">
|
{# Multi-image layout (webtoon style) - no click-through on individual images #}
|
||||||
{% if comic.mobile_filename %}
|
{% for i in range(comic.filenames|length) %}
|
||||||
<picture>
|
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
|
||||||
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
|
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
|
||||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
alt="{{ comic.alt_texts[i] }}"
|
||||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
title="{{ comic.alt_texts[i] }}"
|
||||||
title="{{ comic.alt_text }}">
|
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
|
||||||
</picture>
|
{% endfor %}
|
||||||
{% 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>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if comic.mobile_filename %}
|
{# Single image with click-through to next comic #}
|
||||||
<picture>
|
{% if comic.number < total_comics %}
|
||||||
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
|
<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) }}"
|
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||||
title="{{ comic.alt_text }}">
|
title="{{ comic.alt_text }}"
|
||||||
</picture>
|
loading="eager">
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
{% if comic.mobile_filename %}
|
||||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
<picture>
|
||||||
title="{{ comic.alt_text }}">
|
<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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -51,17 +89,17 @@
|
|||||||
{# Icon-based navigation #}
|
{# Icon-based navigation #}
|
||||||
{% if comic.number > 1 %}
|
{% if comic.number > 1 %}
|
||||||
<a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First">
|
<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>
|
||||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
|
<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>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="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="First">
|
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="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="Previous">
|
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -69,17 +107,17 @@
|
|||||||
|
|
||||||
{% if comic.number < total_comics %}
|
{% if comic.number < total_comics %}
|
||||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
|
<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>
|
||||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
|
<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>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="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="Next">
|
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="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="Latest">
|
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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=1) }}" class="btn btn-nav">First</a>
|
||||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn btn-nav">Previous</a>
|
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn btn-nav">Previous</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-nav btn-disabled">First</span>
|
<span class="btn btn-nav btn-disabled" aria-disabled="true">First</span>
|
||||||
<span class="btn btn-nav btn-disabled">Previous</span>
|
<span class="btn btn-nav btn-disabled" aria-disabled="true">Previous</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<span class="comic-date-display">{{ comic.formatted_date }}</span>
|
<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=comic.number + 1) }}" class="btn btn-nav">Next</a>
|
||||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn btn-nav">Latest</a>
|
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn btn-nav">Latest</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-nav btn-disabled">Next</span>
|
<span class="btn btn-nav btn-disabled" aria-disabled="true">Next</span>
|
||||||
<span class="btn btn-nav btn-disabled">Latest</span>
|
<span class="btn btn-nav btn-disabled" aria-disabled="true">Latest</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if comic.author_note %}
|
||||||
<div class="comic-transcript">
|
<div class="comic-transcript">
|
||||||
<h3>Author Note</h3>
|
<h3>Author Note</h3>
|
||||||
|
|||||||
156
templates/embed.html
Normal file
156
templates/embed.html
Normal 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 }} →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -7,24 +7,38 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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-container" data-comic-number="{{ comic.number }}" data-total-comics="{{ total_comics }}">
|
||||||
<div class="comic-header">
|
<div class="comic-header">
|
||||||
<h1>{{ comic.title if comic.title else '#' ~ comic.number }}</h1>
|
<h1>{{ comic.title if comic.title else '#' ~ comic.number }}</h1>
|
||||||
<p class="comic-date">{{ comic.date }}</p>
|
<p class="comic-date">{{ comic.date }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="comic-image">
|
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
|
||||||
{% if comic.mobile_filename %}
|
{% if comic.is_multi_image %}
|
||||||
<picture>
|
{# Multi-image layout (webtoon style) - no click-through on individual images #}
|
||||||
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
|
{% 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) }}"
|
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||||
title="{{ comic.alt_text }}">
|
title="{{ comic.alt_text }}">
|
||||||
</picture>
|
{% endif %}
|
||||||
{% 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>
|
</div>
|
||||||
|
|
||||||
@@ -34,17 +48,17 @@
|
|||||||
{# Icon-based navigation #}
|
{# Icon-based navigation #}
|
||||||
{% if comic.number > 1 %}
|
{% if comic.number > 1 %}
|
||||||
<a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First">
|
<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>
|
||||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
|
<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>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="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="First">
|
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="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="Previous">
|
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -52,17 +66,17 @@
|
|||||||
|
|
||||||
{% if comic.number < total_comics %}
|
{% if comic.number < total_comics %}
|
||||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
|
<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>
|
||||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
|
<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>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="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="Next">
|
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="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="Latest">
|
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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=1) }}" class="btn btn-nav">First</a>
|
||||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn btn-nav">Previous</a>
|
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn btn-nav">Previous</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-nav btn-disabled">First</span>
|
<span class="btn btn-nav btn-disabled" aria-disabled="true">First</span>
|
||||||
<span class="btn btn-nav btn-disabled">Previous</span>
|
<span class="btn btn-nav btn-disabled" aria-disabled="true">Previous</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<span class="comic-date-display">{{ comic.formatted_date }}</span>
|
<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=comic.number + 1) }}" class="btn btn-nav">Next</a>
|
||||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn btn-nav">Latest</a>
|
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn btn-nav">Latest</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="btn btn-nav btn-disabled">Next</span>
|
<span class="btn btn-nav btn-disabled" aria-disabled="true">Next</span>
|
||||||
<span class="btn btn-nav btn-disabled">Latest</span>
|
<span class="btn btn-nav btn-disabled" aria-disabled="true">Latest</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if comic.author_note %}
|
||||||
<div class="comic-transcript">
|
<div class="comic-transcript">
|
||||||
<h3>Author Note</h3>
|
<h3>Author Note</h3>
|
||||||
|
|||||||
5
version.py
Normal file
5
version.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Sunday Comics Version
|
||||||
|
# This file contains the version number for the project
|
||||||
|
# Format: YYYY.MM.DD (date-based versioning)
|
||||||
|
|
||||||
|
__version__ = "2025.11.15"
|
||||||
Reference in New Issue
Block a user