Compare commits
55 Commits
bc2d4aebeb
...
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 | |||
| f7c32ca749 | |||
| 04cd72b8d8 | |||
| 7a9f64ee17 | |||
| ecbc75e447 | |||
| a79f80a2ea | |||
| 6f1a986661 | |||
| 9f7416427e | |||
| 103c283c61 | |||
| 0e42cc3f33 | |||
| 0fb120c54f | |||
| e3d4315f7f | |||
| 24dd74ae77 | |||
| 899f2060f3 | |||
| 59707d3572 | |||
| 2b6234e2f8 | |||
| 6e3685b4ca | |||
| d484835f5b |
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
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Tomasita Cabrera
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
342
app.py
342
app.py
@@ -1,12 +1,23 @@
|
|||||||
|
# Sunday Comics - A simple webcomic platform
|
||||||
|
# Copyright (c) 2025 Tomasita Cabrera
|
||||||
|
# Licensed under the MIT License - see LICENSE file for details
|
||||||
|
|
||||||
import os
|
import 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, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, HEADER_IMAGE, FOOTER_IMAGE,
|
COMICS, COMIC_NAME, COPYRIGHT_NAME, SITE_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, LOGO_IMAGE, LOGO_MODE,
|
||||||
COMPACT_FOOTER, USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS,
|
HEADER_IMAGE, FOOTER_IMAGE, BANNER_IMAGE, COMPACT_FOOTER, ARCHIVE_FULL_WIDTH, SECTIONS_ENABLED,
|
||||||
USE_FOOTER_SOCIAL_ICONS, SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL
|
USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS, USE_FOOTER_SOCIAL_ICONS, USE_SHARE_ICONS, NEWSLETTER_ENABLED,
|
||||||
|
SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK, EMBED_ENABLED, PERMALINK_ENABLED
|
||||||
)
|
)
|
||||||
import markdown
|
import markdown
|
||||||
|
from version import __version__
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.WARNING)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -14,19 +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,
|
||||||
|
'copyright_name': COPYRIGHT_NAME if COPYRIGHT_NAME else COMIC_NAME,
|
||||||
|
'current_year': datetime.now().year,
|
||||||
|
'site_url': SITE_URL,
|
||||||
|
'logo_image': LOGO_IMAGE,
|
||||||
|
'logo_mode': LOGO_MODE,
|
||||||
'header_image': HEADER_IMAGE,
|
'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,
|
||||||
|
'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,
|
||||||
|
'embed_enabled': EMBED_ENABLED,
|
||||||
|
'permalink_enabled': PERMALINK_ENABLED,
|
||||||
|
'version': __version__
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -60,9 +93,20 @@ def format_comic_date(date_str):
|
|||||||
return date_str
|
return date_str
|
||||||
|
|
||||||
|
|
||||||
def get_author_note(date_str):
|
def get_author_note_from_file(filename):
|
||||||
"""Load author note from markdown file if it exists, using date as filename"""
|
"""Load author note from markdown file if it exists
|
||||||
note_path = os.path.join(os.path.dirname(__file__), 'content', 'author_notes', f'{date_str}.md')
|
|
||||||
|
Args:
|
||||||
|
filename: Either just a filename (looked up in content/author_notes/)
|
||||||
|
or a path relative to content/
|
||||||
|
"""
|
||||||
|
# If filename contains a path separator, treat as relative to content/
|
||||||
|
if '/' in filename or '\\' in filename:
|
||||||
|
note_path = os.path.join(os.path.dirname(__file__), 'content', filename)
|
||||||
|
else:
|
||||||
|
# Just a filename, look in author_notes directory
|
||||||
|
note_path = os.path.join(os.path.dirname(__file__), 'content', 'author_notes', filename)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(note_path, 'r', encoding='utf-8') as f:
|
with open(note_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
@@ -80,13 +124,58 @@ 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'])
|
||||||
|
|
||||||
# Check for markdown author note, fall back to data field if not found
|
# Normalize filename to list for multi-image support
|
||||||
markdown_note = get_author_note(comic['date'])
|
if isinstance(comic.get('filename'), list):
|
||||||
if markdown_note:
|
enriched['filenames'] = comic['filename']
|
||||||
enriched['author_note'] = markdown_note
|
enriched['is_multi_image'] = True
|
||||||
enriched['author_note_is_html'] = True
|
|
||||||
else:
|
else:
|
||||||
# No markdown file, use plain text from comic data if it exists
|
enriched['filenames'] = [comic['filename']] if 'filename' in comic else []
|
||||||
|
enriched['is_multi_image'] = False
|
||||||
|
|
||||||
|
# Normalize alt_text to list matching filenames
|
||||||
|
if isinstance(comic.get('alt_text'), list):
|
||||||
|
enriched['alt_texts'] = comic['alt_text']
|
||||||
|
|
||||||
|
# Warn if alt_text list doesn't match filenames length
|
||||||
|
if len(enriched['alt_texts']) != len(enriched['filenames']):
|
||||||
|
logger.warning(
|
||||||
|
f"Comic #{comic['number']}: alt_text list length ({len(enriched['alt_texts'])}) "
|
||||||
|
f"doesn't match filenames length ({len(enriched['filenames'])}). "
|
||||||
|
f"Tip: Use a single string for alt_text to apply the same text to all images, "
|
||||||
|
f"or provide a list matching the number of images."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If single alt_text string, use it for all images (this is intentional and valid)
|
||||||
|
alt_text = comic.get('alt_text', '')
|
||||||
|
enriched['alt_texts'] = [alt_text] * len(enriched['filenames'])
|
||||||
|
|
||||||
|
# Ensure alt_texts list matches filenames length (pad with empty strings if too short)
|
||||||
|
while len(enriched['alt_texts']) < len(enriched['filenames']):
|
||||||
|
enriched['alt_texts'].append('')
|
||||||
|
|
||||||
|
# Trim alt_texts if too long (extra ones won't be used anyway)
|
||||||
|
if len(enriched['alt_texts']) > len(enriched['filenames']):
|
||||||
|
enriched['alt_texts'] = enriched['alt_texts'][:len(enriched['filenames'])]
|
||||||
|
|
||||||
|
# Keep original filename and alt_text for backward compatibility (first image)
|
||||||
|
if enriched['filenames']:
|
||||||
|
enriched['filename'] = enriched['filenames'][0]
|
||||||
|
|
||||||
|
# Ensure alt_text is always a string (use first one if it's a list)
|
||||||
|
if enriched['alt_texts']:
|
||||||
|
enriched['alt_text'] = enriched['alt_texts'][0]
|
||||||
|
|
||||||
|
# Check for explicitly specified markdown author note file
|
||||||
|
if 'author_note_md' in comic and comic['author_note_md']:
|
||||||
|
markdown_note = get_author_note_from_file(comic['author_note_md'])
|
||||||
|
if markdown_note:
|
||||||
|
enriched['author_note'] = markdown_note
|
||||||
|
enriched['author_note_is_html'] = True
|
||||||
|
else:
|
||||||
|
# File specified but not found, use plain text from comic data if it exists
|
||||||
|
enriched['author_note_is_html'] = False
|
||||||
|
else:
|
||||||
|
# No markdown file specified, use plain text from comic data if it exists
|
||||||
enriched['author_note_is_html'] = False
|
enriched['author_note_is_html'] = False
|
||||||
|
|
||||||
return enriched
|
return enriched
|
||||||
@@ -124,17 +213,73 @@ def comic(comic_id):
|
|||||||
comic = get_comic_by_number(comic_id)
|
comic = get_comic_by_number(comic_id)
|
||||||
if not comic:
|
if not comic:
|
||||||
abort(404)
|
abort(404)
|
||||||
return render_template('comic.html', title=f"Comic #{comic_id}",
|
# Use comic title if present, otherwise use #X format (matching client-side behavior)
|
||||||
|
page_title = comic.get('title', f"#{comic_id}")
|
||||||
|
return render_template('comic.html', title=page_title,
|
||||||
comic=comic, total_comics=len(COMICS))
|
comic=comic, total_comics=len(COMICS))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/embed/<int:comic_id>')
|
||||||
|
def embed(comic_id):
|
||||||
|
"""Embeddable comic view - minimal layout for iframes"""
|
||||||
|
if not EMBED_ENABLED:
|
||||||
|
abort(404)
|
||||||
|
comic = get_comic_by_number(comic_id)
|
||||||
|
if not comic:
|
||||||
|
abort(404)
|
||||||
|
# Use comic title if present, otherwise use #X format
|
||||||
|
page_title = comic.get('title', f"#{comic_id}")
|
||||||
|
return render_template('embed.html', title=page_title, comic=comic)
|
||||||
|
|
||||||
|
|
||||||
|
def group_comics_by_section(comics_list):
|
||||||
|
"""Group comics by section. Returns list of (section_title, comics) tuples"""
|
||||||
|
if not SECTIONS_ENABLED:
|
||||||
|
return [(None, comics_list)]
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
current_section = None
|
||||||
|
current_comics = []
|
||||||
|
|
||||||
|
for comic in comics_list:
|
||||||
|
# Check if this comic starts a new section
|
||||||
|
if 'section' in comic:
|
||||||
|
# Save previous section if it has comics
|
||||||
|
if current_comics:
|
||||||
|
sections.append((current_section, current_comics))
|
||||||
|
# Start new section
|
||||||
|
current_section = comic['section']
|
||||||
|
current_comics = [comic]
|
||||||
|
else:
|
||||||
|
# Add to current section
|
||||||
|
current_comics.append(comic)
|
||||||
|
|
||||||
|
# Don't forget the last section
|
||||||
|
if current_comics:
|
||||||
|
sections.append((current_section, current_comics))
|
||||||
|
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
@app.route('/archive')
|
@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
|
||||||
|
sections = group_comics_by_section(initial_comics)
|
||||||
|
|
||||||
return render_template('archive.html', title='Archive',
|
return render_template('archive.html', title='Archive',
|
||||||
comics=comics)
|
sections=sections,
|
||||||
|
total_comics=len(COMICS),
|
||||||
|
initial_batch=initial_batch)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/about')
|
@app.route('/about')
|
||||||
@@ -151,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>')
|
||||||
@@ -166,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"""
|
||||||
|
|||||||
108
comics_data.py
108
comics_data.py
@@ -1,6 +1,20 @@
|
|||||||
# Comic data
|
# Sunday Comics - Comic data configuration
|
||||||
|
# Copyright (c) 2025 Tomasita Cabrera
|
||||||
|
# Licensed under the MIT License - see LICENSE file for details
|
||||||
|
#
|
||||||
# Edit this file to add, remove, or modify comics
|
# Edit this file to add, remove, or modify comics
|
||||||
|
|
||||||
|
# Global setting: The name of your comic/website
|
||||||
|
COMIC_NAME = 'Sunday Comics'
|
||||||
|
|
||||||
|
# Global setting: The name to display in the copyright notice
|
||||||
|
# If not set (None), defaults to COMIC_NAME
|
||||||
|
COPYRIGHT_NAME = None # e.g., 'Your Name' or 'Your Studio Name'
|
||||||
|
|
||||||
|
# Global setting: Your website's domain (used for RSS feed, Open Graph tags, etc.)
|
||||||
|
# Update this to your production domain when deploying
|
||||||
|
SITE_URL = 'http://localhost:3000'
|
||||||
|
|
||||||
# Global setting: Set to True to make all comics full-width by default
|
# Global setting: Set to True to make all comics full-width by default
|
||||||
# Individual comics can override this with 'full_width': False
|
# Individual comics can override this with 'full_width': False
|
||||||
FULL_WIDTH_DEFAULT = False
|
FULL_WIDTH_DEFAULT = False
|
||||||
@@ -9,19 +23,43 @@ FULL_WIDTH_DEFAULT = False
|
|||||||
# Individual comics can override this with 'plain': False
|
# Individual comics can override this with 'plain': False
|
||||||
PLAIN_DEFAULT = False
|
PLAIN_DEFAULT = False
|
||||||
|
|
||||||
|
# Global setting: Path to site logo (relative to static/images/)
|
||||||
|
# Set to None to disable logo
|
||||||
|
# Example: LOGO_IMAGE = 'logo.png' will use static/images/logo.png
|
||||||
|
LOGO_IMAGE = 'logo.png'
|
||||||
|
|
||||||
|
# Global setting: Logo display mode
|
||||||
|
# 'beside' - Display logo next to the site title
|
||||||
|
# 'replace' - Replace the site title with the logo
|
||||||
|
# Only applies when LOGO_IMAGE is set
|
||||||
|
LOGO_MODE = 'beside'
|
||||||
|
|
||||||
# Global setting: Path to header image (relative to static/images/)
|
# Global setting: Path to header image (relative to static/images/)
|
||||||
# Set to None to disable header image
|
# Set to None to disable header image
|
||||||
# Example: HEADER_IMAGE = 'title.jpg' will use static/images/title.jpg
|
# Example: HEADER_IMAGE = 'title.jpg' will use static/images/title.jpg
|
||||||
HEADER_IMAGE = 'title.png'
|
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
|
||||||
COMPACT_FOOTER = True
|
COMPACT_FOOTER = False
|
||||||
|
|
||||||
|
# Global setting: Set to True to make archive page full-width with 4 columns (2 on mobile)
|
||||||
|
# Full-width archive shows square thumbnails with only dates, no titles
|
||||||
|
ARCHIVE_FULL_WIDTH = True
|
||||||
|
|
||||||
|
# Global setting: Set to True to enable sections/chapters on the archive page
|
||||||
|
# Add 'section': 'Chapter Title' to comics where a new section starts
|
||||||
|
SECTIONS_ENABLED = True
|
||||||
|
|
||||||
# Global setting: Set to True to use icon images for comic navigation buttons
|
# Global setting: Set to True to use icon images for comic navigation buttons
|
||||||
# Icons should be in static/images/icons/ (first.png, previous.png, next.png, latest.png)
|
# Icons should be in static/images/icons/ (first.png, previous.png, next.png, latest.png)
|
||||||
@@ -35,37 +73,41 @@ 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'
|
||||||
SOCIAL_EMAIL = None # e.g., 'mailto:your@email.com'
|
SOCIAL_EMAIL = None # e.g., 'mailto:your@email.com'
|
||||||
|
|
||||||
COMICS = [
|
# API documentation link - set to None to hide the link
|
||||||
{
|
# Path is relative to static/ directory
|
||||||
'number': 1,
|
API_SPEC_LINK = None # Set to 'openapi.yml' to enable
|
||||||
'title': 'First Comic',
|
|
||||||
'filename': 'comic-001.jpg',
|
# Global setting: Set to True to enable comic embed functionality
|
||||||
'mobile_filename': 'comic-001-mobile.jpg', # Optional: mobile version of the comic
|
# When enabled, users can get embed codes to display comics on other websites
|
||||||
'date': '2025-01-01',
|
EMBED_ENABLED = True
|
||||||
'alt_text': 'The very first comic',
|
|
||||||
'author_note': 'This is where your comic journey begins!',
|
# Global setting: Set to True to enable permalink copy button
|
||||||
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT for this comic
|
# When enabled, users can easily copy a direct link to the current comic
|
||||||
'plain': True, # Optional: override PLAIN_DEFAULT for this comic
|
PERMALINK_ENABLED = True
|
||||||
},
|
|
||||||
{
|
# Load comics from YAML files
|
||||||
'number': 2,
|
from data_loader import load_comics_from_yaml, validate_comics
|
||||||
'filename': 'comic-002.jpg',
|
|
||||||
'date': '2025-01-08',
|
COMICS = load_comics_from_yaml('data/comics')
|
||||||
'alt_text': 'The second comic',
|
|
||||||
'full_width': True,
|
# Validate loaded comics
|
||||||
'plain': True,
|
if not validate_comics(COMICS):
|
||||||
},
|
print("Warning: Comic validation failed. Please check your YAML files.")
|
||||||
{
|
|
||||||
'number': 3,
|
# Show loaded comics count
|
||||||
'title': 'Third Comic',
|
if COMICS:
|
||||||
'filename': 'comic-003.jpg',
|
print(f"Loaded {len(COMICS)} comics from data/comics/")
|
||||||
'date': '2025-01-15',
|
else:
|
||||||
'alt_text': 'The third comic',
|
print("Warning: No comics loaded! Please add .yaml files to data/comics/")
|
||||||
'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
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# Sunday Comics - Add comic script
|
||||||
|
# Copyright (c) 2025 Tomasita Cabrera
|
||||||
|
# Licensed under the MIT License - see LICENSE file for details
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Script to add a new comic entry to comics_data.py with reasonable defaults
|
Script to add a new comic entry as a YAML file with reasonable defaults
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -47,52 +51,83 @@ 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')
|
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()
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
# Format new entry
|
# Check if file already exists
|
||||||
entry_str = f""" {{
|
if os.path.exists(yaml_file):
|
||||||
'number': {comic['number']},
|
print(f"Error: Comic file already exists: {yaml_file}")
|
||||||
'filename': {repr(comic['filename'])},
|
sys.exit(1)
|
||||||
'date': {repr(comic['date'])},
|
|
||||||
'alt_text': {repr(comic['alt_text'])}
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
# Insert before closing bracket
|
# Create YAML file with comments
|
||||||
insert_pos = content.rfind(']')
|
yaml_content = f"""# Comic #{number}
|
||||||
new_content = content[:insert_pos] + entry_str + ",\n" + content[insert_pos:]
|
number: {number}
|
||||||
|
filename: {comic_data['filename']}
|
||||||
|
date: "{date_str}"
|
||||||
|
alt_text: "{comic_data['alt_text']}"
|
||||||
|
"""
|
||||||
|
|
||||||
# Write back
|
if args.markdown:
|
||||||
with open(comics_file, 'w') as f:
|
yaml_content += f'\n# Markdown author note (overrides author_note if present)\nauthor_note_md: "{date_str}.md"\n'
|
||||||
f.write(new_content)
|
else:
|
||||||
|
yaml_content += '\n# Optional: Add author note\n# author_note: "Your thoughts about this comic."\n'
|
||||||
|
|
||||||
print(f"Added comic #{number}")
|
yaml_content += """
|
||||||
|
# Optional: Add a title
|
||||||
|
# title: "Title of Your Comic"
|
||||||
|
|
||||||
|
# Optional: Override global settings
|
||||||
|
# full_width: true
|
||||||
|
# plain: true
|
||||||
|
|
||||||
|
# Optional: Start a new section (only add to first comic of section)
|
||||||
|
# section: "Chapter X: Title"
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Write YAML file
|
||||||
|
with open(yaml_file, 'w') as f:
|
||||||
|
f.write(yaml_content)
|
||||||
|
|
||||||
|
print(f"Created comic #{number}: {yaml_file}")
|
||||||
|
|
||||||
# Create markdown file if requested
|
# 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()
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# Sunday Comics - RSS feed generator
|
||||||
|
# Copyright (c) 2025 Tomasita Cabrera
|
||||||
|
# Licensed under the MIT License - see LICENSE file for details
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Script to generate an RSS feed for the comic
|
Script to generate an RSS feed for the comic
|
||||||
"""
|
"""
|
||||||
@@ -10,11 +14,10 @@ from xml.dom import minidom
|
|||||||
|
|
||||||
# Add parent directory to path so we can import comics_data
|
# 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__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from comics_data import COMICS
|
from comics_data import COMICS, COMIC_NAME, SITE_URL
|
||||||
|
|
||||||
# Configuration - update these for your site
|
# Configuration
|
||||||
SITE_URL = "http://localhost:3000" # Change to your actual domain
|
SITE_TITLE = COMIC_NAME
|
||||||
SITE_TITLE = "Sunday Comics"
|
|
||||||
SITE_DESCRIPTION = "A webcomic about life, the universe, and everything"
|
SITE_DESCRIPTION = "A webcomic about life, the universe, and everything"
|
||||||
SITE_LANGUAGE = "en-us"
|
SITE_LANGUAGE = "en-us"
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
@@ -135,6 +199,30 @@ nav .container {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: var(--letter-spacing-wide);
|
letter-spacing: var(--letter-spacing-wide);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
height: 2.5rem;
|
||||||
|
width: auto;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-beside {
|
||||||
|
/* Logo displayed beside the title */
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-replace {
|
||||||
|
/* Logo replaces the title - can be larger */
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
/* Title text when displayed beside logo */
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
@@ -326,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;
|
||||||
@@ -338,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);
|
||||||
@@ -444,6 +561,25 @@ main {
|
|||||||
margin-top: var(--space-md);
|
margin-top: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Section Headers for Archive */
|
||||||
|
.section-header {
|
||||||
|
margin-top: var(--space-2xl);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
border-bottom: var(--border-width-thin) solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--letter-spacing-tight);
|
||||||
|
}
|
||||||
|
|
||||||
.archive-grid {
|
.archive-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(var(--archive-grid-min), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(var(--archive-grid-min), 1fr));
|
||||||
@@ -465,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);
|
||||||
@@ -490,12 +634,52 @@ main {
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-width Archive Mode */
|
||||||
|
.page-header-fullwidth {
|
||||||
|
padding: 0 var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-content-fullwidth {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-grid-fullwidth {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-item-fullwidth img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-item-fullwidth .archive-info {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-item-fullwidth .archive-date {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nav-brand a {
|
.nav-brand a {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-replace {
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
@@ -537,12 +721,26 @@ main {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-grid-fullwidth {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-fullwidth {
|
||||||
|
padding: 0 var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-content-fullwidth {
|
||||||
|
padding: 0 var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
.comic-container {
|
.comic-container {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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;
|
||||||
@@ -571,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 */
|
||||||
@@ -672,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 {
|
||||||
@@ -686,6 +971,35 @@ 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 {
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--letter-spacing-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom a.api-link:hover {
|
||||||
|
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;
|
||||||
@@ -694,10 +1008,12 @@ 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;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer.compact-footer .footer-content {
|
footer.compact-footer .footer-content {
|
||||||
@@ -705,7 +1021,7 @@ footer.compact-footer .footer-content {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-md);
|
gap: var(--space-md);
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer.compact-footer .footer-section {
|
footer.compact-footer .footer-section {
|
||||||
@@ -745,6 +1061,8 @@ footer.compact-footer .footer-bottom {
|
|||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
footer.compact-footer .footer-bottom::before {
|
footer.compact-footer .footer-bottom::before {
|
||||||
@@ -758,9 +1076,252 @@ footer.compact-footer .footer-bottom p {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
footer.compact-footer .footer-bottom a.api-link {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer.compact-footer .footer-bottom a.api-link::before {
|
||||||
|
content: '|';
|
||||||
|
margin: 0 var(--space-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Add separator between sections in compact mode */
|
/* Add separator between sections in compact mode */
|
||||||
footer.compact-footer .footer-section:not(:last-child)::after {
|
footer.compact-footer .footer-section:not(:last-child)::after {
|
||||||
content: '|';
|
content: '|';
|
||||||
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/logo.png
LFS
Normal file
BIN
static/images/logo.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);
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
let totalComics = 0;
|
let totalComics = 0;
|
||||||
|
let comicName = ''; // Will be extracted from initial page title
|
||||||
|
let currentComicNumber = 0;
|
||||||
|
let lazyLoadObserver = null;
|
||||||
|
|
||||||
// Fetch and display a comic
|
// Fetch and display a comic
|
||||||
async function loadComic(comicId) {
|
async function loadComic(comicId) {
|
||||||
@@ -28,6 +31,13 @@
|
|||||||
// Update the page with comic data
|
// Update the page with comic data
|
||||||
function displayComic(comic) {
|
function displayComic(comic) {
|
||||||
const title = comic.title || `#${comic.number}`;
|
const title = comic.title || `#${comic.number}`;
|
||||||
|
currentComicNumber = comic.number;
|
||||||
|
|
||||||
|
// Announce comic change to screen readers
|
||||||
|
const announcer = document.getElementById('comic-announcer');
|
||||||
|
if (announcer) {
|
||||||
|
announcer.textContent = `Loaded comic ${title}, dated ${comic.formatted_date || comic.date}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Update container class for full-width option
|
// Update container class for full-width option
|
||||||
const container = document.querySelector('.comic-container');
|
const container = document.querySelector('.comic-container');
|
||||||
@@ -45,7 +55,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide header based on plain mode
|
// Show/hide header based on plain mode
|
||||||
const header = document.querySelector('.comic-header');
|
let header = document.querySelector('.comic-header');
|
||||||
if (comic.plain) {
|
if (comic.plain) {
|
||||||
if (header) {
|
if (header) {
|
||||||
header.style.display = 'none';
|
header.style.display = 'none';
|
||||||
@@ -59,21 +69,29 @@
|
|||||||
newHeader.className = 'comic-header';
|
newHeader.className = 'comic-header';
|
||||||
newHeader.innerHTML = '<h1></h1><p class="comic-date"></p>';
|
newHeader.innerHTML = '<h1></h1><p class="comic-date"></p>';
|
||||||
container.insertBefore(newHeader, container.firstChild);
|
container.insertBefore(newHeader, container.firstChild);
|
||||||
|
header = newHeader; // Update reference to the newly created element
|
||||||
}
|
}
|
||||||
// Update title and date
|
// Update title and date using the header reference
|
||||||
document.querySelector('.comic-header h1').textContent = title;
|
header.querySelector('h1').textContent = title;
|
||||||
document.querySelector('.comic-date').textContent = comic.date;
|
header.querySelector('.comic-date').textContent = comic.date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update image and its link
|
// Update image and its link
|
||||||
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
|
||||||
const transcriptDiv = document.querySelector('.comic-transcript');
|
let transcriptDiv = document.querySelector('.comic-transcript');
|
||||||
if (comic.author_note) {
|
if (comic.author_note) {
|
||||||
if (!transcriptDiv) {
|
if (!transcriptDiv) {
|
||||||
const container = document.querySelector('.comic-container');
|
const container = document.querySelector('.comic-container');
|
||||||
@@ -81,6 +99,7 @@
|
|||||||
newDiv.className = 'comic-transcript';
|
newDiv.className = 'comic-transcript';
|
||||||
newDiv.innerHTML = '<h3>Author Note</h3>';
|
newDiv.innerHTML = '<h3>Author Note</h3>';
|
||||||
container.appendChild(newDiv);
|
container.appendChild(newDiv);
|
||||||
|
transcriptDiv = newDiv; // Update reference to the newly created element
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear existing content after the h3
|
// Clear existing content after the h3
|
||||||
@@ -109,10 +128,52 @@
|
|||||||
updateNavButtons(comic.number, comic.formatted_date);
|
updateNavButtons(comic.number, comic.formatted_date);
|
||||||
|
|
||||||
// Update page title
|
// Update page title
|
||||||
document.title = `${title} - Sunday Comics`;
|
document.title = `${title} - ${comicName}`;
|
||||||
|
|
||||||
// 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
|
||||||
@@ -120,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,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);
|
||||||
@@ -183,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
|
||||||
@@ -193,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
|
||||||
@@ -208,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
|
||||||
@@ -218,9 +352,72 @@
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
function handleKeyboardNavigation(event) {
|
||||||
|
// Don't interfere if user is typing in an input field
|
||||||
|
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const announcer = document.getElementById('comic-announcer');
|
||||||
|
|
||||||
|
switch(event.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
// Previous comic
|
||||||
|
if (currentComicNumber > 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
loadComic(currentComicNumber - 1);
|
||||||
|
} else if (announcer) {
|
||||||
|
announcer.textContent = 'Already at the first comic';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
// Next comic
|
||||||
|
if (currentComicNumber < totalComics) {
|
||||||
|
event.preventDefault();
|
||||||
|
loadComic(currentComicNumber + 1);
|
||||||
|
} else if (announcer) {
|
||||||
|
announcer.textContent = 'Already at the latest comic';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
// First comic
|
||||||
|
if (currentComicNumber > 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
loadComic(1);
|
||||||
|
} else if (announcer) {
|
||||||
|
announcer.textContent = 'Already at the first comic';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
// Latest comic
|
||||||
|
if (currentComicNumber < totalComics) {
|
||||||
|
event.preventDefault();
|
||||||
|
loadComic(totalComics);
|
||||||
|
} else if (announcer) {
|
||||||
|
announcer.textContent = 'Already at the latest comic';
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,18 +428,35 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract comic name from initial page title (e.g., "Latest Comic - Sunday Comics" -> "Sunday Comics")
|
||||||
|
const titleParts = document.title.split(' - ');
|
||||||
|
if (titleParts.length > 1) {
|
||||||
|
comicName = titleParts[titleParts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
// Get total comics count from the page
|
// Get total comics count from the page
|
||||||
totalComics = parseInt(document.querySelector('.comic-container').dataset.totalComics || 0);
|
totalComics = parseInt(document.querySelector('.comic-container').dataset.totalComics || 0);
|
||||||
|
|
||||||
// Get current comic number
|
// Get current comic number
|
||||||
const currentNumber = parseInt(document.querySelector('.comic-container').dataset.comicNumber || 0);
|
const currentNumber = parseInt(document.querySelector('.comic-container').dataset.comicNumber || 0);
|
||||||
|
currentComicNumber = currentNumber;
|
||||||
|
|
||||||
if (currentNumber && totalComics) {
|
if (currentNumber && totalComics) {
|
||||||
// Get the formatted date from the DOM (already rendered by server)
|
// Get the formatted date from the DOM (already rendered by server)
|
||||||
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
|
||||||
@@ -252,6 +466,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
document.addEventListener('keydown', handleKeyboardNavigation);
|
||||||
|
|
||||||
// Set initial state
|
// Set initial state
|
||||||
history.replaceState({ comicId: currentNumber }, '', window.location.pathname);
|
history.replaceState({ comicId: currentNumber }, '', window.location.pathname);
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
327
static/openapi.yaml
Normal file
327
static/openapi.yaml
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Webcomic API
|
||||||
|
description: API for accessing webcomic data
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
url: http://127.0.0.1:3000
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://127.0.0.1:3000
|
||||||
|
description: Development server
|
||||||
|
- url: https://your-production-domain.com
|
||||||
|
description: Production server (update with your actual domain)
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/api/comics:
|
||||||
|
get:
|
||||||
|
summary: Get all comics
|
||||||
|
description: |
|
||||||
|
Returns all comics with enriched metadata. Supports optional pagination and section grouping.
|
||||||
|
|
||||||
|
**Without pagination parameters:** Returns a simple array of all comics (newest first when using pagination, original order otherwise).
|
||||||
|
|
||||||
|
**With pagination parameters:** Returns paginated response with section grouping (if enabled globally or via `group_by_section` parameter).
|
||||||
|
operationId: getAllComics
|
||||||
|
tags:
|
||||||
|
- Comics
|
||||||
|
parameters:
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
description: Page number for pagination (1-indexed). When provided, triggers paginated response format.
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
default: 1
|
||||||
|
example: 1
|
||||||
|
- name: per_page
|
||||||
|
in: query
|
||||||
|
description: Number of comics per page (max 100). When provided, triggers paginated response format.
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
default: 24
|
||||||
|
example: 24
|
||||||
|
- name: group_by_section
|
||||||
|
in: query
|
||||||
|
description: Force section grouping in response (even when SECTIONS_ENABLED is false). When true, triggers paginated response format.
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
example: false
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful response
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
oneOf:
|
||||||
|
- type: array
|
||||||
|
description: Simple array response (when no pagination parameters provided)
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Comic'
|
||||||
|
- $ref: '#/components/schemas/PaginatedComicsResponse'
|
||||||
|
examples:
|
||||||
|
simpleArray:
|
||||||
|
summary: Simple array response (default)
|
||||||
|
value:
|
||||||
|
- number: 1
|
||||||
|
title: "First Comic"
|
||||||
|
filename: "comic-001.jpg"
|
||||||
|
mobile_filename: "comic-001-mobile.jpg"
|
||||||
|
date: "2025-01-01"
|
||||||
|
alt_text: "The very first comic"
|
||||||
|
author_note: "This is where your comic journey begins!"
|
||||||
|
full_width: true
|
||||||
|
plain: true
|
||||||
|
formatted_date: "Wednesday, January 1, 2025"
|
||||||
|
author_note_is_html: false
|
||||||
|
- number: 2
|
||||||
|
filename: "comic-002.jpg"
|
||||||
|
date: "2025-01-08"
|
||||||
|
alt_text: "The second comic"
|
||||||
|
full_width: true
|
||||||
|
plain: true
|
||||||
|
formatted_date: "Wednesday, January 8, 2025"
|
||||||
|
author_note_is_html: false
|
||||||
|
paginatedResponse:
|
||||||
|
summary: Paginated response (when using page/per_page parameters)
|
||||||
|
value:
|
||||||
|
sections:
|
||||||
|
- section_title: "Chapter 1"
|
||||||
|
comics:
|
||||||
|
- number: 2
|
||||||
|
filename: "comic-002.jpg"
|
||||||
|
date: "2025-01-08"
|
||||||
|
alt_text: "The second comic"
|
||||||
|
full_width: true
|
||||||
|
plain: true
|
||||||
|
formatted_date: "Wednesday, January 8, 2025"
|
||||||
|
author_note_is_html: false
|
||||||
|
- section_title: null
|
||||||
|
comics:
|
||||||
|
- number: 1
|
||||||
|
title: "First Comic"
|
||||||
|
filename: "comic-001.jpg"
|
||||||
|
date: "2025-01-01"
|
||||||
|
alt_text: "The very first comic"
|
||||||
|
full_width: true
|
||||||
|
plain: true
|
||||||
|
formatted_date: "Wednesday, January 1, 2025"
|
||||||
|
author_note_is_html: false
|
||||||
|
page: 1
|
||||||
|
per_page: 24
|
||||||
|
total_comics: 2
|
||||||
|
has_more: false
|
||||||
|
|
||||||
|
/api/comics/{comic_id}:
|
||||||
|
get:
|
||||||
|
summary: Get a specific comic
|
||||||
|
description: Returns a single comic by its number/ID with enriched metadata
|
||||||
|
operationId: getComicById
|
||||||
|
tags:
|
||||||
|
- Comics
|
||||||
|
parameters:
|
||||||
|
- name: comic_id
|
||||||
|
in: path
|
||||||
|
description: Comic number/ID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
example: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Successful response with comic data
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Comic'
|
||||||
|
example:
|
||||||
|
number: 1
|
||||||
|
title: "First Comic"
|
||||||
|
filename: "comic-001.jpg"
|
||||||
|
mobile_filename: "comic-001-mobile.jpg"
|
||||||
|
date: "2025-01-01"
|
||||||
|
alt_text: "The very first comic"
|
||||||
|
author_note: "This is where your comic journey begins!"
|
||||||
|
full_width: true
|
||||||
|
plain: true
|
||||||
|
formatted_date: "Wednesday, January 1, 2025"
|
||||||
|
author_note_is_html: false
|
||||||
|
'404':
|
||||||
|
description: Comic not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
example:
|
||||||
|
error: "Comic not found"
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Comic:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- number
|
||||||
|
- filename
|
||||||
|
- date
|
||||||
|
- alt_text
|
||||||
|
- full_width
|
||||||
|
- plain
|
||||||
|
- formatted_date
|
||||||
|
- author_note_is_html
|
||||||
|
- filenames
|
||||||
|
- alt_texts
|
||||||
|
- is_multi_image
|
||||||
|
properties:
|
||||||
|
number:
|
||||||
|
type: integer
|
||||||
|
description: Sequential comic number (unique identifier)
|
||||||
|
minimum: 1
|
||||||
|
example: 1
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Comic title (optional, defaults to "#X" if not provided)
|
||||||
|
example: "First Comic"
|
||||||
|
filename:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
description: Single image filename in static/images/comics/
|
||||||
|
- type: array
|
||||||
|
description: Multiple image filenames for webtoon-style comics
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: "comic-001.jpg"
|
||||||
|
filenames:
|
||||||
|
type: array
|
||||||
|
description: Normalized array of image filenames (computed field, always an array even for single images)
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["comic-001.jpg"]
|
||||||
|
mobile_filename:
|
||||||
|
type: string
|
||||||
|
description: Optional mobile version of the comic image
|
||||||
|
example: "comic-001-mobile.jpg"
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Publication date in YYYY-MM-DD format
|
||||||
|
example: "2025-01-01"
|
||||||
|
alt_text:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
description: Accessibility text for single image or shared text for all images
|
||||||
|
- type: array
|
||||||
|
description: Individual accessibility text for each image in multi-image comics
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: "The very first comic"
|
||||||
|
alt_texts:
|
||||||
|
type: array
|
||||||
|
description: Normalized array of alt texts matching filenames (computed field)
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: ["The very first comic"]
|
||||||
|
is_multi_image:
|
||||||
|
type: boolean
|
||||||
|
description: Indicates if this is a multi-image comic (computed field)
|
||||||
|
example: false
|
||||||
|
author_note:
|
||||||
|
type: string
|
||||||
|
description: Author's note about the comic (plain text or HTML from markdown)
|
||||||
|
example: "This is where your comic journey begins!"
|
||||||
|
author_note_md:
|
||||||
|
type: string
|
||||||
|
description: Filename or path to markdown file for author note
|
||||||
|
example: "2025-01-01.md"
|
||||||
|
section:
|
||||||
|
type: string
|
||||||
|
description: Section/chapter title (appears on archive page when SECTIONS_ENABLED is true)
|
||||||
|
example: "Chapter 1: Origins"
|
||||||
|
full_width:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the comic should display in full-width mode (computed from global default and per-comic override)
|
||||||
|
example: true
|
||||||
|
plain:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the comic should display in plain mode without header/borders (computed from global default and per-comic override)
|
||||||
|
example: true
|
||||||
|
formatted_date:
|
||||||
|
type: string
|
||||||
|
description: Human-readable formatted date (computed field)
|
||||||
|
example: "Wednesday, January 1, 2025"
|
||||||
|
author_note_is_html:
|
||||||
|
type: boolean
|
||||||
|
description: Indicates whether author_note contains HTML (from markdown) or plain text (computed field)
|
||||||
|
example: false
|
||||||
|
|
||||||
|
ComicSection:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- section_title
|
||||||
|
- comics
|
||||||
|
properties:
|
||||||
|
section_title:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Section/chapter title (null for comics without a section)
|
||||||
|
example: "Chapter 1"
|
||||||
|
comics:
|
||||||
|
type: array
|
||||||
|
description: Comics in this section
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Comic'
|
||||||
|
|
||||||
|
PaginatedComicsResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- sections
|
||||||
|
- page
|
||||||
|
- per_page
|
||||||
|
- total_comics
|
||||||
|
- has_more
|
||||||
|
properties:
|
||||||
|
sections:
|
||||||
|
type: array
|
||||||
|
description: Comics grouped by section
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ComicSection'
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
description: Current page number
|
||||||
|
minimum: 1
|
||||||
|
example: 1
|
||||||
|
per_page:
|
||||||
|
type: integer
|
||||||
|
description: Number of comics per page
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100
|
||||||
|
example: 24
|
||||||
|
total_comics:
|
||||||
|
type: integer
|
||||||
|
description: Total number of comics across all pages
|
||||||
|
minimum: 0
|
||||||
|
example: 100
|
||||||
|
has_more:
|
||||||
|
type: boolean
|
||||||
|
description: Whether there are more pages available
|
||||||
|
example: true
|
||||||
|
|
||||||
|
Error:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- error
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: Error message
|
||||||
|
example: "Comic not found"
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Comics
|
||||||
|
description: Operations for accessing comic data
|
||||||
@@ -1,26 +1,51 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
{% if archive_full_width %}
|
||||||
|
</div> {# Close container for full-width mode #}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="page-header{% if archive_full_width %} page-header-fullwidth{% endif %}">
|
||||||
<h1>Comic Archive</h1>
|
<h1>Comic Archive</h1>
|
||||||
<p>Browse all {{ comics|length }} comics</p>
|
<p>Browse all {{ total_comics }} comics</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="archive-content">
|
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}"
|
||||||
<div class="archive-grid">
|
data-total-comics="{{ total_comics }}"
|
||||||
{% for comic in comics %}
|
data-initial-batch="{{ initial_batch }}">
|
||||||
<div class="archive-item">
|
{% for section_title, section_comics in sections %}
|
||||||
<a href="{{ url_for('comic', comic_id=comic.number) }}">
|
{% if section_title and sections_enabled %}
|
||||||
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
|
<div class="section-header">
|
||||||
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
|
<h2>{{ section_title }}</h2>
|
||||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}">
|
|
||||||
<div class="archive-info">
|
|
||||||
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
|
|
||||||
<p class="archive-date">{{ comic.date }}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
<div class="archive-grid{% if archive_full_width %} archive-grid-fullwidth{% endif %}">
|
||||||
|
{% for comic in section_comics %}
|
||||||
|
<div class="archive-item{% if archive_full_width %} archive-item-fullwidth{% endif %}">
|
||||||
|
<a href="{{ url_for('comic', comic_id=comic.number) }}">
|
||||||
|
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
|
||||||
|
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
|
||||||
|
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||||
|
loading="lazy">
|
||||||
|
<div class="archive-info">
|
||||||
|
{% if not archive_full_width %}
|
||||||
|
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
|
||||||
|
{% endif %}
|
||||||
|
<p class="archive-date">{{ comic.date }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if archive_full_width %}
|
||||||
|
<div class="container"> {# Reopen container for footer #}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{{ url_for('static', filename='js/archive-lazy-load.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,17 +3,25 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}{{ title }}{% endblock %} - Sunday Comics</title>
|
<title>{% block title %}{{ title }}{% endblock %} - {{ comic_name }}</title>
|
||||||
|
|
||||||
<!-- 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">
|
||||||
<meta property="og:url" content="{% block meta_url %}{{ request.url }}{% endblock %}">
|
<meta property="og:url" content="{% block meta_url %}{{ site_url }}{{ request.path }}{% endblock %}">
|
||||||
<meta property="og:title" content="{% block og_title %}{{ self.title() }} - Sunday Comics{% endblock %}">
|
<meta property="og:title" content="{% block og_title %}{{ self.title() }} - {{ comic_name }}{% endblock %}">
|
||||||
<meta property="og:description" content="{% block og_description %}{{ self.meta_description() }}{% endblock %}">
|
<meta property="og:description" content="{% block og_description %}{{ self.meta_description() }}{% endblock %}">
|
||||||
<meta property="og:image" content="{% block og_image %}{{ request.url_root }}static/images/default-preview.png{% endblock %}">
|
<meta property="og:image" content="{% block og_image %}{{ site_url }}/static/images/default-preview.png{% endblock %}">
|
||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta property="twitter:card" content="summary_large_image">
|
<meta property="twitter:card" content="summary_large_image">
|
||||||
@@ -29,13 +37,16 @@
|
|||||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
<link rel="alternate" type="application/rss+xml" title="Sunday Comics 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="Sunday Comics Header">
|
<img src="{{ url_for('static', filename='images/' + header_image) }}" alt="{{ comic_name }} Header">
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -44,23 +55,32 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
{% if not header_image %}
|
{% if not header_image %}
|
||||||
<div class="nav-brand">
|
<div class="nav-brand">
|
||||||
<a href="{{ url_for('index') }}">Sunday Comics</a>
|
<a href="{{ url_for('index') }}">
|
||||||
|
{% if logo_image and logo_mode == 'beside' %}
|
||||||
|
<img src="{{ url_for('static', filename='images/' + logo_image) }}" alt="{{ comic_name }} Logo" class="nav-logo nav-logo-beside">
|
||||||
|
<span class="nav-title">{{ comic_name }}</span>
|
||||||
|
{% elif logo_image and logo_mode == 'replace' %}
|
||||||
|
<img src="{{ url_for('static', filename='images/' + logo_image) }}" alt="{{ comic_name }}" class="nav-logo nav-logo-replace">
|
||||||
|
{% else %}
|
||||||
|
{{ comic_name }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<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>
|
||||||
@@ -68,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>
|
||||||
@@ -83,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 %}
|
||||||
@@ -92,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 %}
|
||||||
@@ -101,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 %}
|
||||||
@@ -109,14 +129,18 @@
|
|||||||
{% 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 %}
|
||||||
</a>
|
</a>
|
||||||
|
{% if api_spec_link %}
|
||||||
|
<a href="{{ url_for('static', filename=api_spec_link) }}" aria-label="API">API</a>
|
||||||
|
{% endif %}
|
||||||
</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 -->
|
||||||
@@ -126,21 +150,69 @@
|
|||||||
</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 Sunday Comics. 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>
|
||||||
|
|
||||||
{% if footer_image %}
|
{% if footer_image %}
|
||||||
<div class="site-footer-image">
|
<div class="site-footer-image">
|
||||||
<img src="{{ url_for('static', filename='images/' + footer_image) }}" alt="Sunday Comics Footer">
|
<img src="{{ url_for('static', filename='images/' + footer_image) }}" alt="{{ comic_name }} Footer">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Embed Code Modal -->
|
||||||
|
{% if embed_enabled %}
|
||||||
|
<div id="embed-modal" class="modal" role="dialog" aria-labelledby="embed-modal-title" aria-hidden="true">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="embed-modal-title">Embed This Comic</h2>
|
||||||
|
<button class="modal-close" aria-label="Close embed modal">×</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>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
@@ -2,9 +2,33 @@
|
|||||||
|
|
||||||
{% block meta_description %}{{ comic.alt_text }}{% if comic.author_note %} - {{ comic.author_note }}{% endif %}{% endblock %}
|
{% block meta_description %}{{ comic.alt_text }}{% if comic.author_note %} - {{ comic.author_note }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block og_image %}{{ request.url_root }}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>
|
||||||
@@ -3,28 +3,42 @@
|
|||||||
{% if comic %}
|
{% if comic %}
|
||||||
{% block meta_description %}{{ comic.alt_text }}{% if comic.author_note %} - {{ comic.author_note }}{% endif %}{% endblock %}
|
{% block meta_description %}{{ comic.alt_text }}{% if comic.author_note %} - {{ comic.author_note }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block og_image %}{{ request.url_root }}static/images/thumbs/{{ comic.filename }}{% endblock %}
|
{% block og_image %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endblock %}
|
||||||
{% 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