Compare commits
75 Commits
b6f6ee4b70
...
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 | |||
| bc2d4aebeb | |||
| e4e65db802 | |||
| 2ac7405cf4 | |||
| 2846576c2f | |||
| 9bd3cdf552 | |||
| 5cfe3f5056 | |||
| c218797d0b | |||
| ddf20d0f7f | |||
| 83ea55adc3 | |||
| bf8ed23bc4 | |||
| 8abb185c02 | |||
| 4657d85dde | |||
| 376333bb42 | |||
| b936b852e9 | |||
| 9cb726312a | |||
| ed0a1aadb2 | |||
| 234d78d862 | |||
| a69f64de7a | |||
| 52920e8fa8 | |||
| 30d9044950 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
||||
|
||||
# This should be generated on deploy
|
||||
static/feed.rss
|
||||
static/sitemap.xml
|
||||
|
||||
# Comic data cache
|
||||
data/comics/.comics_cache.pkl
|
||||
29
CHANGELOG.md
Normal file
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.
|
||||
416
app.py
416
app.py
@@ -1,6 +1,23 @@
|
||||
# Sunday Comics - A simple webcomic platform
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template, abort, jsonify, request
|
||||
from comics_data import COMICS
|
||||
from comics_data import (
|
||||
COMICS, COMIC_NAME, COPYRIGHT_NAME, SITE_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, LOGO_IMAGE, LOGO_MODE,
|
||||
HEADER_IMAGE, FOOTER_IMAGE, BANNER_IMAGE, COMPACT_FOOTER, ARCHIVE_FULL_WIDTH, SECTIONS_ENABLED,
|
||||
USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS, USE_FOOTER_SOCIAL_ICONS, USE_SHARE_ICONS, NEWSLETTER_ENABLED,
|
||||
SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK, EMBED_ENABLED, PERMALINK_ENABLED
|
||||
)
|
||||
import markdown
|
||||
from version import __version__
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -8,18 +25,174 @@ app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key')
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_ai_blocking_headers(response):
|
||||
"""Add headers to discourage AI scraping"""
|
||||
response.headers['X-Robots-Tag'] = 'noai, noimageai'
|
||||
return response
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_global_settings():
|
||||
"""Make global settings available to all templates"""
|
||||
return {
|
||||
'comic_name': COMIC_NAME,
|
||||
'copyright_name': COPYRIGHT_NAME if COPYRIGHT_NAME else COMIC_NAME,
|
||||
'current_year': datetime.now().year,
|
||||
'site_url': SITE_URL,
|
||||
'logo_image': LOGO_IMAGE,
|
||||
'logo_mode': LOGO_MODE,
|
||||
'header_image': HEADER_IMAGE,
|
||||
'footer_image': FOOTER_IMAGE,
|
||||
'banner_image': BANNER_IMAGE,
|
||||
'compact_footer': COMPACT_FOOTER,
|
||||
'archive_full_width': ARCHIVE_FULL_WIDTH,
|
||||
'sections_enabled': SECTIONS_ENABLED,
|
||||
'use_comic_nav_icons': USE_COMIC_NAV_ICONS,
|
||||
'use_header_nav_icons': USE_HEADER_NAV_ICONS,
|
||||
'use_footer_social_icons': USE_FOOTER_SOCIAL_ICONS,
|
||||
'use_share_icons': USE_SHARE_ICONS,
|
||||
'newsletter_enabled': NEWSLETTER_ENABLED,
|
||||
'social_instagram': SOCIAL_INSTAGRAM,
|
||||
'social_youtube': SOCIAL_YOUTUBE,
|
||||
'social_email': SOCIAL_EMAIL,
|
||||
'api_spec_link': API_SPEC_LINK,
|
||||
'embed_enabled': EMBED_ENABLED,
|
||||
'permalink_enabled': PERMALINK_ENABLED,
|
||||
'version': __version__
|
||||
}
|
||||
|
||||
|
||||
def is_full_width(comic):
|
||||
"""Determine if a comic should be full width based on global and per-comic settings"""
|
||||
# If comic explicitly sets full_width, use that value
|
||||
if 'full_width' in comic:
|
||||
return comic['full_width']
|
||||
# Otherwise use the global default
|
||||
return FULL_WIDTH_DEFAULT
|
||||
|
||||
|
||||
def is_plain(comic):
|
||||
"""Determine if a comic should be plain mode based on global and per-comic settings"""
|
||||
# If comic explicitly sets plain, use that value
|
||||
if 'plain' in comic:
|
||||
return comic['plain']
|
||||
# Otherwise use the global default
|
||||
return PLAIN_DEFAULT
|
||||
|
||||
|
||||
def format_comic_date(date_str):
|
||||
"""Format date string (YYYY-MM-DD) to 'Day name, Month name day, year'"""
|
||||
try:
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
# Use %d and strip leading zero for cross-platform compatibility
|
||||
day = date_obj.strftime('%d').lstrip('0')
|
||||
formatted = date_obj.strftime(f'%A, %B {day}, %Y')
|
||||
return formatted
|
||||
except:
|
||||
return date_str
|
||||
|
||||
|
||||
def get_author_note_from_file(filename):
|
||||
"""Load author note from markdown file if it exists
|
||||
|
||||
Args:
|
||||
filename: Either just a filename (looked up in content/author_notes/)
|
||||
or a path relative to content/
|
||||
"""
|
||||
# If filename contains a path separator, treat as relative to content/
|
||||
if '/' in filename or '\\' in filename:
|
||||
note_path = os.path.join(os.path.dirname(__file__), 'content', filename)
|
||||
else:
|
||||
# Just a filename, look in author_notes directory
|
||||
note_path = os.path.join(os.path.dirname(__file__), 'content', 'author_notes', filename)
|
||||
|
||||
try:
|
||||
with open(note_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
return markdown.markdown(content)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def enrich_comic(comic):
|
||||
"""Add computed properties to comic data"""
|
||||
if comic is None:
|
||||
return None
|
||||
enriched = comic.copy()
|
||||
enriched['full_width'] = is_full_width(comic)
|
||||
enriched['plain'] = is_plain(comic)
|
||||
enriched['formatted_date'] = format_comic_date(comic['date'])
|
||||
|
||||
# Normalize filename to list for multi-image support
|
||||
if isinstance(comic.get('filename'), list):
|
||||
enriched['filenames'] = comic['filename']
|
||||
enriched['is_multi_image'] = True
|
||||
else:
|
||||
enriched['filenames'] = [comic['filename']] if 'filename' in comic else []
|
||||
enriched['is_multi_image'] = False
|
||||
|
||||
# Normalize alt_text to list matching filenames
|
||||
if isinstance(comic.get('alt_text'), list):
|
||||
enriched['alt_texts'] = comic['alt_text']
|
||||
|
||||
# Warn if alt_text list doesn't match filenames length
|
||||
if len(enriched['alt_texts']) != len(enriched['filenames']):
|
||||
logger.warning(
|
||||
f"Comic #{comic['number']}: alt_text list length ({len(enriched['alt_texts'])}) "
|
||||
f"doesn't match filenames length ({len(enriched['filenames'])}). "
|
||||
f"Tip: Use a single string for alt_text to apply the same text to all images, "
|
||||
f"or provide a list matching the number of images."
|
||||
)
|
||||
else:
|
||||
# If single alt_text string, use it for all images (this is intentional and valid)
|
||||
alt_text = comic.get('alt_text', '')
|
||||
enriched['alt_texts'] = [alt_text] * len(enriched['filenames'])
|
||||
|
||||
# Ensure alt_texts list matches filenames length (pad with empty strings if too short)
|
||||
while len(enriched['alt_texts']) < len(enriched['filenames']):
|
||||
enriched['alt_texts'].append('')
|
||||
|
||||
# Trim alt_texts if too long (extra ones won't be used anyway)
|
||||
if len(enriched['alt_texts']) > len(enriched['filenames']):
|
||||
enriched['alt_texts'] = enriched['alt_texts'][:len(enriched['filenames'])]
|
||||
|
||||
# Keep original filename and alt_text for backward compatibility (first image)
|
||||
if enriched['filenames']:
|
||||
enriched['filename'] = enriched['filenames'][0]
|
||||
|
||||
# Ensure alt_text is always a string (use first one if it's a list)
|
||||
if enriched['alt_texts']:
|
||||
enriched['alt_text'] = enriched['alt_texts'][0]
|
||||
|
||||
# Check for explicitly specified markdown author note file
|
||||
if 'author_note_md' in comic and comic['author_note_md']:
|
||||
markdown_note = get_author_note_from_file(comic['author_note_md'])
|
||||
if markdown_note:
|
||||
enriched['author_note'] = markdown_note
|
||||
enriched['author_note_is_html'] = True
|
||||
else:
|
||||
# File specified but not found, use plain text from comic data if it exists
|
||||
enriched['author_note_is_html'] = False
|
||||
else:
|
||||
# No markdown file specified, use plain text from comic data if it exists
|
||||
enriched['author_note_is_html'] = False
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
def get_comic_by_number(number):
|
||||
"""Get a comic by its number"""
|
||||
for comic in COMICS:
|
||||
if comic['number'] == number:
|
||||
return comic
|
||||
return enrich_comic(comic)
|
||||
return None
|
||||
|
||||
|
||||
def get_latest_comic():
|
||||
"""Get the most recent comic"""
|
||||
if COMICS:
|
||||
return COMICS[-1]
|
||||
return enrich_comic(COMICS[-1])
|
||||
return None
|
||||
|
||||
|
||||
@@ -40,29 +213,176 @@ def comic(comic_id):
|
||||
comic = get_comic_by_number(comic_id)
|
||||
if not comic:
|
||||
abort(404)
|
||||
return render_template('comic.html', title=f"Comic #{comic_id}",
|
||||
# Use comic title if present, otherwise use #X format (matching client-side behavior)
|
||||
page_title = comic.get('title', f"#{comic_id}")
|
||||
return render_template('comic.html', title=page_title,
|
||||
comic=comic, total_comics=len(COMICS))
|
||||
|
||||
|
||||
@app.route('/embed/<int:comic_id>')
|
||||
def embed(comic_id):
|
||||
"""Embeddable comic view - minimal layout for iframes"""
|
||||
if not EMBED_ENABLED:
|
||||
abort(404)
|
||||
comic = get_comic_by_number(comic_id)
|
||||
if not comic:
|
||||
abort(404)
|
||||
# Use comic title if present, otherwise use #X format
|
||||
page_title = comic.get('title', f"#{comic_id}")
|
||||
return render_template('embed.html', title=page_title, comic=comic)
|
||||
|
||||
|
||||
def group_comics_by_section(comics_list):
|
||||
"""Group comics by section. Returns list of (section_title, comics) tuples"""
|
||||
if not SECTIONS_ENABLED:
|
||||
return [(None, comics_list)]
|
||||
|
||||
sections = []
|
||||
current_section = None
|
||||
current_comics = []
|
||||
|
||||
for comic in comics_list:
|
||||
# Check if this comic starts a new section
|
||||
if 'section' in comic:
|
||||
# Save previous section if it has comics
|
||||
if current_comics:
|
||||
sections.append((current_section, current_comics))
|
||||
# Start new section
|
||||
current_section = comic['section']
|
||||
current_comics = [comic]
|
||||
else:
|
||||
# Add to current section
|
||||
current_comics.append(comic)
|
||||
|
||||
# Don't forget the last section
|
||||
if current_comics:
|
||||
sections.append((current_section, current_comics))
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
@app.route('/archive')
|
||||
def archive():
|
||||
"""Archive page showing all comics"""
|
||||
# Initial batch size for server-side rendering
|
||||
initial_batch = 24
|
||||
|
||||
# Reverse order to show newest first
|
||||
comics = list(reversed(COMICS))
|
||||
all_comics = [enrich_comic(comic) for comic in reversed(COMICS)]
|
||||
|
||||
# Only take the first batch for initial render
|
||||
initial_comics = all_comics[:initial_batch]
|
||||
|
||||
# Group by section if enabled
|
||||
sections = group_comics_by_section(initial_comics)
|
||||
|
||||
return render_template('archive.html', title='Archive',
|
||||
comics=comics)
|
||||
sections=sections,
|
||||
total_comics=len(COMICS),
|
||||
initial_batch=initial_batch)
|
||||
|
||||
|
||||
@app.route('/about')
|
||||
def about():
|
||||
"""About page"""
|
||||
return render_template('about.html', title='About')
|
||||
# Read and render the markdown file
|
||||
about_path = os.path.join(os.path.dirname(__file__), 'content', 'about.md')
|
||||
try:
|
||||
with open(about_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
html_content = markdown.markdown(content)
|
||||
except FileNotFoundError:
|
||||
html_content = '<p>About content not found.</p>'
|
||||
return render_template('page.html', title='About', content=html_content)
|
||||
|
||||
|
||||
@app.route('/terms')
|
||||
def terms():
|
||||
"""Terms of Service page"""
|
||||
from jinja2 import Template
|
||||
# Read and render the markdown file with template variables
|
||||
terms_path = os.path.join(os.path.dirname(__file__), 'content', 'terms.md')
|
||||
try:
|
||||
with open(terms_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
# First render as Jinja template to substitute variables
|
||||
template = Template(content)
|
||||
rendered_content = template.render(
|
||||
copyright_name=COPYRIGHT_NAME,
|
||||
social_email=SOCIAL_EMAIL if SOCIAL_EMAIL else '[Contact Email]'
|
||||
)
|
||||
# Then convert markdown to HTML
|
||||
html_content = markdown.markdown(rendered_content)
|
||||
except FileNotFoundError:
|
||||
html_content = '<p>Terms of Service content not found.</p>'
|
||||
return render_template('page.html', title='Terms of Service', content=html_content)
|
||||
|
||||
|
||||
@app.route('/api/comics')
|
||||
def api_comics():
|
||||
"""API endpoint - returns all comics as JSON"""
|
||||
return jsonify(COMICS)
|
||||
"""API endpoint - returns all comics as JSON (optionally paginated with sections)"""
|
||||
# Check for pagination parameters
|
||||
page = request.args.get('page', type=int)
|
||||
per_page = request.args.get('per_page', type=int)
|
||||
group_by_section = request.args.get('group_by_section', 'false').lower() in ('true', '1', 'yes')
|
||||
|
||||
# If no pagination requested, return simple array (backward compatible)
|
||||
if page is None and per_page is None and not group_by_section:
|
||||
return jsonify([enrich_comic(comic) for comic in COMICS])
|
||||
|
||||
# Pagination requested - return paginated response
|
||||
page = page or 1
|
||||
per_page = per_page or 24
|
||||
|
||||
# Limit per_page to reasonable values
|
||||
per_page = min(max(per_page, 1), 100)
|
||||
|
||||
# Reverse order to show newest first
|
||||
all_comics = [enrich_comic(comic) for comic in reversed(COMICS)]
|
||||
|
||||
# Group by section if enabled globally or requested via parameter
|
||||
sections = group_comics_by_section(all_comics) if (SECTIONS_ENABLED or group_by_section) else [(None, all_comics)]
|
||||
|
||||
# Calculate pagination
|
||||
total_comics = len(all_comics)
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
|
||||
# Handle section-aware pagination
|
||||
result_sections = []
|
||||
current_idx = 0
|
||||
|
||||
for section_title, section_comics in sections:
|
||||
section_start = current_idx
|
||||
section_end = current_idx + len(section_comics)
|
||||
|
||||
# Check if this section overlaps with our requested page
|
||||
if section_end > start_idx and section_start < end_idx:
|
||||
# Calculate which comics from this section to include
|
||||
comics_start = max(0, start_idx - section_start)
|
||||
comics_end = min(len(section_comics), end_idx - section_start)
|
||||
|
||||
paginated_comics = section_comics[comics_start:comics_end]
|
||||
|
||||
if paginated_comics:
|
||||
result_sections.append({
|
||||
'section_title': section_title,
|
||||
'comics': paginated_comics
|
||||
})
|
||||
|
||||
current_idx = section_end
|
||||
|
||||
# Stop if we've gone past the requested range
|
||||
if current_idx >= end_idx:
|
||||
break
|
||||
|
||||
return jsonify({
|
||||
'sections': result_sections,
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total_comics': total_comics,
|
||||
'has_more': end_idx < total_comics
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/comics/<int:comic_id>')
|
||||
@@ -74,6 +394,84 @@ def api_comic(comic_id):
|
||||
return jsonify(comic)
|
||||
|
||||
|
||||
@app.route('/sitemap.xml')
|
||||
def sitemap():
|
||||
"""Serve the static sitemap.xml file"""
|
||||
from flask import send_from_directory
|
||||
return send_from_directory('static', 'sitemap.xml', mimetype='application/xml')
|
||||
|
||||
|
||||
@app.route('/robots.txt')
|
||||
def robots():
|
||||
"""Generate robots.txt dynamically with correct SITE_URL"""
|
||||
from flask import Response
|
||||
robots_txt = f"""# Sunday Comics - Robots.txt
|
||||
# Content protected by copyright. AI training prohibited.
|
||||
# See terms: {SITE_URL}/terms
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Sitemap location
|
||||
Sitemap: {SITE_URL}/sitemap.xml
|
||||
|
||||
# Disallow API endpoints from indexing
|
||||
Disallow: /api/
|
||||
|
||||
# Block AI crawlers and scrapers
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
|
||||
User-agent: CCBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: Claude-Web
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Omgilibot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Diffbot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Bytespider
|
||||
Disallow: /
|
||||
|
||||
User-agent: FacebookBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ImagesiftBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: cohere-ai
|
||||
Disallow: /
|
||||
"""
|
||||
return Response(robots_txt, mimetype='text/plain')
|
||||
|
||||
|
||||
@app.route('/tdmrep.json')
|
||||
def tdm_reservation():
|
||||
"""TDM (Text and Data Mining) reservation - signals AI training prohibition"""
|
||||
return jsonify({
|
||||
"tdm": {
|
||||
"reservation": 1,
|
||||
"policy": f"{SITE_URL}/terms"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
"""404 error handler"""
|
||||
|
||||
136
comics_data.py
136
comics_data.py
@@ -1,27 +1,113 @@
|
||||
# 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
|
||||
|
||||
COMICS = [
|
||||
{
|
||||
'number': 1,
|
||||
'title': 'First Comic',
|
||||
'filename': 'comic-001.jpg',
|
||||
'date': '2025-01-01',
|
||||
'alt_text': 'The very first comic',
|
||||
'author_note': 'This is where your comic journey begins!'
|
||||
},
|
||||
{
|
||||
'number': 2,
|
||||
'filename': 'comic-002.jpg',
|
||||
'date': '2025-01-08',
|
||||
'alt_text': 'The second comic',
|
||||
},
|
||||
{
|
||||
'number': 3,
|
||||
'title': 'Third Comic',
|
||||
'filename': 'comic-003.jpg',
|
||||
'date': '2025-01-15',
|
||||
'alt_text': 'The third comic',
|
||||
'author_note': 'Things are getting interesting!'
|
||||
},
|
||||
]
|
||||
# Global setting: 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
|
||||
# Individual comics can override this with 'full_width': False
|
||||
FULL_WIDTH_DEFAULT = False
|
||||
|
||||
# Global setting: Set to True to hide header and remove nav border by default
|
||||
# Individual comics can override this with 'plain': False
|
||||
PLAIN_DEFAULT = False
|
||||
|
||||
# Global setting: Path to site logo (relative to static/images/)
|
||||
# Set to None to disable logo
|
||||
# Example: LOGO_IMAGE = 'logo.png' will use static/images/logo.png
|
||||
LOGO_IMAGE = 'logo.png'
|
||||
|
||||
# Global setting: Logo display mode
|
||||
# 'beside' - Display logo next to the site title
|
||||
# 'replace' - Replace the site title with the logo
|
||||
# Only applies when LOGO_IMAGE is set
|
||||
LOGO_MODE = 'beside'
|
||||
|
||||
# Global setting: Path to header image (relative to static/images/)
|
||||
# Set to None to disable header image
|
||||
# Example: HEADER_IMAGE = 'title.jpg' will use static/images/title.jpg
|
||||
HEADER_IMAGE = None
|
||||
|
||||
# Global setting: Path to footer image (relative to static/images/)
|
||||
# Set to None to disable footer image
|
||||
# Example: FOOTER_IMAGE = 'footer.jpg' will use static/images/footer.jpg
|
||||
FOOTER_IMAGE = None
|
||||
|
||||
# Global setting: Path to shareable banner image (relative to static/images/)
|
||||
# Set to None to disable "Link to Us" section in footer
|
||||
# Example: BANNER_IMAGE = 'banner.jpg' will use static/images/banner.jpg
|
||||
BANNER_IMAGE = 'banner.jpg'
|
||||
|
||||
# Global setting: Set to True to display footer in compact mode
|
||||
# Compact mode: single line, no border, horizontal layout
|
||||
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
|
||||
# Icons should be in static/images/icons/ (first.png, previous.png, next.png, latest.png)
|
||||
USE_COMIC_NAV_ICONS = True
|
||||
|
||||
# Global setting: Set to True to show icons next to main header navigation text
|
||||
# Uses alert.png for Latest, archive.png for Archive, info.png for About
|
||||
USE_HEADER_NAV_ICONS = True
|
||||
|
||||
# Global setting: Set to True to use icons instead of text for footer social links
|
||||
# Uses instagram.png, youtube.png, and mail.png from static/images/icons/
|
||||
USE_FOOTER_SOCIAL_ICONS = True
|
||||
|
||||
# Global setting: Set to True to show icons in share buttons (permalink and embed)
|
||||
# Uses link.png for permalink and embed.png for embed from static/images/icons/
|
||||
USE_SHARE_ICONS = True
|
||||
|
||||
# Global setting: Set to True to show newsletter section in footer
|
||||
NEWSLETTER_ENABLED = False
|
||||
|
||||
# Social media links - set to None to hide the link
|
||||
SOCIAL_INSTAGRAM = None # e.g., 'https://instagram.com/yourhandle'
|
||||
SOCIAL_YOUTUBE = None # e.g., 'https://youtube.com/@yourchannel'
|
||||
SOCIAL_EMAIL = None # e.g., 'mailto:your@email.com'
|
||||
|
||||
# API documentation link - set to None to hide the link
|
||||
# Path is relative to static/ directory
|
||||
API_SPEC_LINK = None # Set to 'openapi.yml' to enable
|
||||
|
||||
# Global setting: Set to True to enable comic embed functionality
|
||||
# When enabled, users can get embed codes to display comics on other websites
|
||||
EMBED_ENABLED = True
|
||||
|
||||
# Global setting: Set to True to enable permalink copy button
|
||||
# When enabled, users can easily copy a direct link to the current comic
|
||||
PERMALINK_ENABLED = True
|
||||
|
||||
# Load comics from YAML files
|
||||
from data_loader import load_comics_from_yaml, validate_comics
|
||||
|
||||
COMICS = load_comics_from_yaml('data/comics')
|
||||
|
||||
# Validate loaded comics
|
||||
if not validate_comics(COMICS):
|
||||
print("Warning: Comic validation failed. Please check your YAML files.")
|
||||
|
||||
# Show loaded comics count
|
||||
if COMICS:
|
||||
print(f"Loaded {len(COMICS)} comics from data/comics/")
|
||||
else:
|
||||
print("Warning: No comics loaded! Please add .yaml files to data/comics/")
|
||||
|
||||
19
content/about.md
Normal file
19
content/about.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# About Sunday Comics
|
||||
|
||||
Welcome to Sunday Comics, a webcomic about life, humor, and everything in between.
|
||||
|
||||
## About the Comic
|
||||
|
||||
Sunday Comics is updated regularly with new strips. Each comic tells a story, shares a laugh, or offers a unique perspective on everyday situations.
|
||||
|
||||
## About the Author
|
||||
|
||||
Sunday Comics is created by [Your Name]. [Add your bio here]
|
||||
|
||||
## Updates
|
||||
|
||||
New comics are posted [specify your schedule, e.g., "every Monday and Thursday" or "weekly"].
|
||||
|
||||
## Support
|
||||
|
||||
If you enjoy Sunday Comics, consider sharing it with friends or supporting the comic through [Patreon/Ko-fi/etc.].
|
||||
7
content/author_notes/2025-01-01.md
Normal file
7
content/author_notes/2025-01-01.md
Normal file
@@ -0,0 +1,7 @@
|
||||
This is where your comic journey begins!
|
||||
|
||||
You can use **markdown** for author notes with support for:
|
||||
|
||||
- **Bold** and *italic* text
|
||||
- [Links](https://example.com)
|
||||
- Lists and more!
|
||||
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 +1,3 @@
|
||||
Flask==3.0.0
|
||||
markdown==3.5.1
|
||||
PyYAML==6.0.3
|
||||
@@ -1,9 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sunday Comics - Add comic script
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
"""
|
||||
Script to add a new comic entry to comics_data.py with reasonable defaults
|
||||
Script to add a new comic entry as a YAML file with reasonable defaults
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent directory to path so we can import comics_data
|
||||
@@ -11,45 +16,118 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from comics_data import COMICS
|
||||
|
||||
|
||||
def create_markdown_file(date_str, parent_dir):
|
||||
"""Create a markdown file for author notes"""
|
||||
author_notes_dir = os.path.join(parent_dir, 'content', 'author_notes')
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(author_notes_dir, exist_ok=True)
|
||||
|
||||
markdown_file = os.path.join(author_notes_dir, f'{date_str}.md')
|
||||
|
||||
# Check if file already exists
|
||||
if os.path.exists(markdown_file):
|
||||
print(f"Warning: Markdown file already exists: {markdown_file}")
|
||||
return markdown_file
|
||||
|
||||
# Create file with template content
|
||||
template = f"""# Author Note
|
||||
|
||||
Write your author note here using markdown formatting.
|
||||
|
||||
**Example formatting:**
|
||||
- *Italic text*
|
||||
- **Bold text**
|
||||
- [Links](https://example.com)
|
||||
- `Code snippets`
|
||||
"""
|
||||
|
||||
with open(markdown_file, 'w') as f:
|
||||
f.write(template)
|
||||
|
||||
print(f"Created markdown file: {markdown_file}")
|
||||
return markdown_file
|
||||
|
||||
|
||||
def main():
|
||||
"""Add a new comic entry with defaults"""
|
||||
parser = argparse.ArgumentParser(description='Add a new comic entry as a YAML file')
|
||||
parser.add_argument('-m', '--markdown', action='store_true',
|
||||
help='Generate a markdown file for author notes and add author_note_md field to comic entry')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get next number
|
||||
number = max(comic['number'] for comic in COMICS) + 1 if COMICS else 1
|
||||
|
||||
# Get today's date
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# Create entry with defaults
|
||||
comic = {
|
||||
comic_data = {
|
||||
'number': number,
|
||||
'filename': f'comic-{number:03d}.png',
|
||||
'date': datetime.now().strftime('%Y-%m-%d'),
|
||||
'date': date_str,
|
||||
'alt_text': f'Comic #{number}',
|
||||
}
|
||||
|
||||
# Get path to comics_data.py
|
||||
# Add markdown reference if requested
|
||||
if args.markdown:
|
||||
comic_data['author_note_md'] = f'{date_str}.md'
|
||||
|
||||
# Get paths
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(script_dir)
|
||||
comics_file = os.path.join(parent_dir, 'comics_data.py')
|
||||
comics_dir = os.path.join(parent_dir, 'data', 'comics')
|
||||
yaml_file = os.path.join(comics_dir, f'{number:03d}.yaml')
|
||||
|
||||
# Read file
|
||||
with open(comics_file, 'r') as f:
|
||||
content = f.read()
|
||||
# Create comics directory if it doesn't exist
|
||||
os.makedirs(comics_dir, exist_ok=True)
|
||||
|
||||
# Format new entry
|
||||
entry_str = f""" {{
|
||||
'number': {comic['number']},
|
||||
'filename': {repr(comic['filename'])},
|
||||
'date': {repr(comic['date'])},
|
||||
'alt_text': {repr(comic['alt_text'])}
|
||||
}}"""
|
||||
# Check if file already exists
|
||||
if os.path.exists(yaml_file):
|
||||
print(f"Error: Comic file already exists: {yaml_file}")
|
||||
sys.exit(1)
|
||||
|
||||
# Insert before closing bracket
|
||||
insert_pos = content.rfind(']')
|
||||
new_content = content[:insert_pos] + entry_str + ",\n" + content[insert_pos:]
|
||||
# Create YAML file with comments
|
||||
yaml_content = f"""# Comic #{number}
|
||||
number: {number}
|
||||
filename: {comic_data['filename']}
|
||||
date: "{date_str}"
|
||||
alt_text: "{comic_data['alt_text']}"
|
||||
"""
|
||||
|
||||
# Write back
|
||||
with open(comics_file, 'w') as f:
|
||||
f.write(new_content)
|
||||
if args.markdown:
|
||||
yaml_content += f'\n# Markdown author note (overrides author_note if present)\nauthor_note_md: "{date_str}.md"\n'
|
||||
else:
|
||||
yaml_content += '\n# Optional: Add author note\n# author_note: "Your thoughts about this comic."\n'
|
||||
|
||||
print(f"Added comic #{number}")
|
||||
yaml_content += """
|
||||
# Optional: Add a title
|
||||
# title: "Title of Your Comic"
|
||||
|
||||
# Optional: Override global settings
|
||||
# full_width: true
|
||||
# plain: true
|
||||
|
||||
# Optional: Start a new section (only add to first comic of section)
|
||||
# section: "Chapter X: Title"
|
||||
"""
|
||||
|
||||
# Write YAML file
|
||||
with open(yaml_file, 'w') as f:
|
||||
f.write(yaml_content)
|
||||
|
||||
print(f"Created comic #{number}: {yaml_file}")
|
||||
|
||||
# Create markdown file if requested
|
||||
if args.markdown:
|
||||
create_markdown_file(date_str, parent_dir)
|
||||
|
||||
print(f"\nNext steps:")
|
||||
print(f"1. Add your comic image as: static/images/comics/{comic_data['filename']}")
|
||||
print(f"2. Edit {yaml_file} to customize the comic metadata")
|
||||
if args.markdown:
|
||||
print(f"3. Edit content/author_notes/{date_str}.md to write your author note")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
154
scripts/bump_version.py
Executable file
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
|
||||
# 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
|
||||
"""
|
||||
@@ -10,11 +14,10 @@ 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
|
||||
from comics_data import COMICS, COMIC_NAME, SITE_URL
|
||||
|
||||
# Configuration - update these for your site
|
||||
SITE_URL = "http://localhost:3000" # Change to your actual domain
|
||||
SITE_TITLE = "Sunday Comics"
|
||||
# Configuration
|
||||
SITE_TITLE = COMIC_NAME
|
||||
SITE_DESCRIPTION = "A webcomic about life, the universe, and everything"
|
||||
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()
|
||||
1178
static/css/style.css
1178
static/css/style.css
File diff suppressed because it is too large
Load Diff
BIN
static/images/banner.jpg
LFS
Normal file
BIN
static/images/banner.jpg
LFS
Normal file
Binary file not shown.
BIN
static/images/comics/comic-001-mobile.jpg
LFS
Normal file
BIN
static/images/comics/comic-001-mobile.jpg
LFS
Normal file
Binary file not shown.
BIN
static/images/footer.jpg
LFS
Normal file
BIN
static/images/footer.jpg
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/alert.png
LFS
Normal file
BIN
static/images/icons/alert.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/archive.png
LFS
Normal file
BIN
static/images/icons/archive.png
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/first.png
LFS
Normal file
BIN
static/images/icons/first.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/info.png
LFS
Normal file
BIN
static/images/icons/info.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/instagram.png
LFS
Normal file
BIN
static/images/icons/instagram.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/latest.png
LFS
Normal file
BIN
static/images/icons/latest.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/icons/mail .png
LFS
Normal file
BIN
static/images/icons/mail .png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/next.png
LFS
Normal file
BIN
static/images/icons/next.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/previous.png
LFS
Normal file
BIN
static/images/icons/previous.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/rss.png
LFS
Normal file
BIN
static/images/icons/rss.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/youtube.png
LFS
Normal file
BIN
static/images/icons/youtube.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.
BIN
static/images/title.png
LFS
Normal file
BIN
static/images/title.png
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';
|
||||
|
||||
let totalComics = 0;
|
||||
let comicName = ''; // Will be extracted from initial page title
|
||||
let currentComicNumber = 0;
|
||||
let lazyLoadObserver = null;
|
||||
|
||||
// Fetch and display a comic
|
||||
async function loadComic(comicId) {
|
||||
@@ -27,118 +30,394 @@
|
||||
|
||||
// Update the page with comic data
|
||||
function displayComic(comic) {
|
||||
// Update title
|
||||
const title = comic.title || `#${comic.number}`;
|
||||
document.querySelector('.comic-header h1').textContent = title;
|
||||
currentComicNumber = comic.number;
|
||||
|
||||
// Update date
|
||||
document.querySelector('.comic-date').textContent = comic.date;
|
||||
// Announce comic change to screen readers
|
||||
const announcer = document.getElementById('comic-announcer');
|
||||
if (announcer) {
|
||||
announcer.textContent = `Loaded comic ${title}, dated ${comic.formatted_date || comic.date}`;
|
||||
}
|
||||
|
||||
// Update container class for full-width option
|
||||
const container = document.querySelector('.comic-container');
|
||||
if (comic.full_width) {
|
||||
container.classList.add('comic-container-fullwidth');
|
||||
} else {
|
||||
container.classList.remove('comic-container-fullwidth');
|
||||
}
|
||||
|
||||
// Update container class for plain mode
|
||||
if (comic.plain) {
|
||||
container.classList.add('comic-container-plain');
|
||||
} else {
|
||||
container.classList.remove('comic-container-plain');
|
||||
}
|
||||
|
||||
// Show/hide header based on plain mode
|
||||
let header = document.querySelector('.comic-header');
|
||||
if (comic.plain) {
|
||||
if (header) {
|
||||
header.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
if (header) {
|
||||
header.style.display = 'block';
|
||||
} else {
|
||||
// Create header if it doesn't exist
|
||||
const newHeader = document.createElement('div');
|
||||
newHeader.className = 'comic-header';
|
||||
newHeader.innerHTML = '<h1></h1><p class="comic-date"></p>';
|
||||
container.insertBefore(newHeader, container.firstChild);
|
||||
header = newHeader; // Update reference to the newly created element
|
||||
}
|
||||
// Update title and date using the header reference
|
||||
header.querySelector('h1').textContent = title;
|
||||
header.querySelector('.comic-date').textContent = comic.date;
|
||||
}
|
||||
|
||||
// Update image and its link
|
||||
const comicImageDiv = document.querySelector('.comic-image');
|
||||
const img = comicImageDiv.querySelector('img');
|
||||
img.src = `/static/images/comics/${comic.filename}`;
|
||||
img.alt = title;
|
||||
img.title = comic.alt_text;
|
||||
updateComicImage(comicImageDiv, comic, title);
|
||||
|
||||
// Update or create/remove the link wrapper
|
||||
// Update or create/remove the link wrapper (only for single-image comics)
|
||||
if (!comic.is_multi_image) {
|
||||
updateComicImageLink(comic.number);
|
||||
}
|
||||
|
||||
// Initialize lazy loading for multi-image comics
|
||||
if (comic.is_multi_image) {
|
||||
initLazyLoad();
|
||||
}
|
||||
|
||||
// Update author note
|
||||
const transcriptDiv = document.querySelector('.comic-transcript');
|
||||
let transcriptDiv = document.querySelector('.comic-transcript');
|
||||
if (comic.author_note) {
|
||||
if (!transcriptDiv) {
|
||||
const container = document.querySelector('.comic-container');
|
||||
const newDiv = document.createElement('div');
|
||||
newDiv.className = 'comic-transcript';
|
||||
newDiv.innerHTML = '<h3>Author Note</h3><p></p>';
|
||||
newDiv.innerHTML = '<h3>Author Note</h3>';
|
||||
container.appendChild(newDiv);
|
||||
transcriptDiv = newDiv; // Update reference to the newly created element
|
||||
}
|
||||
document.querySelector('.comic-transcript p').textContent = comic.author_note;
|
||||
document.querySelector('.comic-transcript').style.display = 'block';
|
||||
|
||||
// Clear existing content after the h3
|
||||
const h3 = transcriptDiv.querySelector('h3');
|
||||
while (h3.nextSibling) {
|
||||
h3.nextSibling.remove();
|
||||
}
|
||||
|
||||
// Add content based on whether it's HTML or plain text
|
||||
if (comic.author_note_is_html) {
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.innerHTML = comic.author_note;
|
||||
transcriptDiv.appendChild(contentDiv);
|
||||
} else {
|
||||
const contentP = document.createElement('p');
|
||||
contentP.textContent = comic.author_note;
|
||||
transcriptDiv.appendChild(contentP);
|
||||
}
|
||||
|
||||
transcriptDiv.style.display = 'block';
|
||||
} else if (transcriptDiv) {
|
||||
transcriptDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update navigation buttons
|
||||
updateNavButtons(comic.number);
|
||||
updateNavButtons(comic.number, comic.formatted_date);
|
||||
|
||||
// Update page title
|
||||
document.title = `${title} - Sunday Comics`;
|
||||
document.title = `${title} - ${comicName}`;
|
||||
|
||||
// Update URL without reload
|
||||
history.pushState({ comicId: comic.number }, '', `/comic/${comic.number}`);
|
||||
|
||||
// Dispatch custom event for other features (like embed button)
|
||||
window.dispatchEvent(new CustomEvent('comicUpdated', {
|
||||
detail: { comicNumber: comic.number }
|
||||
}));
|
||||
|
||||
// Move focus to comic image for keyboard navigation accessibility
|
||||
const comicImageFocus = document.getElementById('comic-image-focus');
|
||||
if (comicImageFocus) {
|
||||
comicImageFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize lazy loading for multi-image comics
|
||||
function initLazyLoad() {
|
||||
// Disconnect existing observer if any
|
||||
if (lazyLoadObserver) {
|
||||
lazyLoadObserver.disconnect();
|
||||
}
|
||||
|
||||
// Create Intersection Observer for lazy loading
|
||||
lazyLoadObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
const src = img.getAttribute('data-src');
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.removeAttribute('data-src');
|
||||
img.classList.add('loaded');
|
||||
lazyLoadObserver.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '50px' // Start loading 50px before image enters viewport
|
||||
});
|
||||
|
||||
// Observe all lazy-load images
|
||||
document.querySelectorAll('.lazy-load').forEach(img => {
|
||||
lazyLoadObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
// Update or create comic image with optional mobile version
|
||||
function updateComicImage(comicImageDiv, comic, title) {
|
||||
// Clear all existing content
|
||||
comicImageDiv.innerHTML = '';
|
||||
|
||||
// Update container class for multi-image
|
||||
if (comic.is_multi_image) {
|
||||
comicImageDiv.classList.add('comic-image-multi');
|
||||
} else {
|
||||
comicImageDiv.classList.remove('comic-image-multi');
|
||||
}
|
||||
|
||||
// Create new image element(s)
|
||||
if (comic.is_multi_image) {
|
||||
// Multi-image comic (webtoon style)
|
||||
comic.filenames.forEach((filename, index) => {
|
||||
const img = document.createElement('img');
|
||||
|
||||
if (index === 0) {
|
||||
// First image loads immediately
|
||||
img.src = `/static/images/comics/${filename}`;
|
||||
img.loading = 'eager';
|
||||
} else {
|
||||
// Subsequent images lazy load
|
||||
img.setAttribute('data-src', `/static/images/comics/${filename}`);
|
||||
img.classList.add('lazy-load');
|
||||
img.loading = 'lazy';
|
||||
}
|
||||
|
||||
img.alt = comic.alt_texts[index] || '';
|
||||
img.title = comic.alt_texts[index] || '';
|
||||
comicImageDiv.appendChild(img);
|
||||
});
|
||||
} else {
|
||||
// Single image comic
|
||||
if (comic.mobile_filename) {
|
||||
// Create picture element with mobile source
|
||||
const picture = document.createElement('picture');
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update comic image link for click navigation
|
||||
function updateComicImageLink(currentNumber) {
|
||||
const comicImageDiv = document.querySelector('.comic-image');
|
||||
const img = comicImageDiv.querySelector('img');
|
||||
const imgOrPicture = comicImageDiv.querySelector('picture') || comicImageDiv.querySelector('img');
|
||||
|
||||
// Remove existing link if present
|
||||
const existingLink = comicImageDiv.querySelector('a');
|
||||
if (existingLink) {
|
||||
existingLink.replaceWith(img);
|
||||
existingLink.replaceWith(imgOrPicture);
|
||||
}
|
||||
|
||||
// Add link if there's a next comic
|
||||
if (currentNumber < totalComics) {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/comic/${currentNumber + 1}`;
|
||||
link.setAttribute('aria-label', 'Click to view next comic');
|
||||
link.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
loadComic(currentNumber + 1);
|
||||
};
|
||||
img.parentNode.insertBefore(link, img);
|
||||
link.appendChild(img);
|
||||
imgOrPicture.parentNode.insertBefore(link, imgOrPicture);
|
||||
link.appendChild(imgOrPicture);
|
||||
}
|
||||
}
|
||||
|
||||
// Update navigation button states
|
||||
function updateNavButtons(currentNumber) {
|
||||
function updateNavButtons(currentNumber, formattedDate) {
|
||||
const navButtons = document.querySelector('.nav-buttons');
|
||||
|
||||
// Detect if using icon navigation
|
||||
const isIconNav = navButtons.children[0].classList.contains('btn-icon-nav');
|
||||
|
||||
// First button
|
||||
const firstBtn = navButtons.children[0];
|
||||
if (currentNumber > 1) {
|
||||
firstBtn.className = 'btn btn-nav';
|
||||
firstBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||
firstBtn.onclick = (e) => { e.preventDefault(); loadComic(1); };
|
||||
firstBtn.removeAttribute('aria-disabled');
|
||||
if (firstBtn.tagName === 'SPAN') {
|
||||
firstBtn.setAttribute('tabindex', '0');
|
||||
firstBtn.setAttribute('role', 'button');
|
||||
firstBtn.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadComic(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
firstBtn.className = 'btn btn-nav btn-disabled';
|
||||
firstBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||
firstBtn.onclick = null;
|
||||
firstBtn.onkeydown = null;
|
||||
firstBtn.setAttribute('aria-disabled', 'true');
|
||||
firstBtn.removeAttribute('tabindex');
|
||||
}
|
||||
|
||||
// Previous button
|
||||
const prevBtn = navButtons.children[1];
|
||||
if (currentNumber > 1) {
|
||||
prevBtn.className = 'btn btn-nav';
|
||||
prevBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||
prevBtn.onclick = (e) => { e.preventDefault(); loadComic(currentNumber - 1); };
|
||||
prevBtn.removeAttribute('aria-disabled');
|
||||
if (prevBtn.tagName === 'SPAN') {
|
||||
prevBtn.setAttribute('tabindex', '0');
|
||||
prevBtn.setAttribute('role', 'button');
|
||||
prevBtn.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadComic(currentNumber - 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
prevBtn.className = 'btn btn-nav btn-disabled';
|
||||
prevBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||
prevBtn.onclick = null;
|
||||
prevBtn.onkeydown = null;
|
||||
prevBtn.setAttribute('aria-disabled', 'true');
|
||||
prevBtn.removeAttribute('tabindex');
|
||||
}
|
||||
|
||||
// Comic number display
|
||||
navButtons.children[2].textContent = `Comic #${currentNumber}`;
|
||||
// Comic date display
|
||||
if (formattedDate) {
|
||||
navButtons.children[2].textContent = formattedDate;
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextBtn = navButtons.children[3];
|
||||
if (currentNumber < totalComics) {
|
||||
nextBtn.className = 'btn btn-nav';
|
||||
nextBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||
nextBtn.onclick = (e) => { e.preventDefault(); loadComic(currentNumber + 1); };
|
||||
nextBtn.removeAttribute('aria-disabled');
|
||||
if (nextBtn.tagName === 'SPAN') {
|
||||
nextBtn.setAttribute('tabindex', '0');
|
||||
nextBtn.setAttribute('role', 'button');
|
||||
nextBtn.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadComic(currentNumber + 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
nextBtn.className = 'btn btn-nav btn-disabled';
|
||||
nextBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||
nextBtn.onclick = null;
|
||||
nextBtn.onkeydown = null;
|
||||
nextBtn.setAttribute('aria-disabled', 'true');
|
||||
nextBtn.removeAttribute('tabindex');
|
||||
}
|
||||
|
||||
// Latest button
|
||||
const latestBtn = navButtons.children[4];
|
||||
if (currentNumber < totalComics) {
|
||||
latestBtn.className = 'btn btn-nav';
|
||||
latestBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||
latestBtn.onclick = (e) => { e.preventDefault(); loadComic(totalComics); };
|
||||
latestBtn.removeAttribute('aria-disabled');
|
||||
if (latestBtn.tagName === 'SPAN') {
|
||||
latestBtn.setAttribute('tabindex', '0');
|
||||
latestBtn.setAttribute('role', 'button');
|
||||
latestBtn.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadComic(totalComics);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
latestBtn.className = 'btn btn-nav btn-disabled';
|
||||
latestBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||
latestBtn.onclick = null;
|
||||
latestBtn.onkeydown = null;
|
||||
latestBtn.setAttribute('aria-disabled', 'true');
|
||||
latestBtn.removeAttribute('tabindex');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard navigation
|
||||
function handleKeyboardNavigation(event) {
|
||||
// Don't interfere if user is typing in an input field
|
||||
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const announcer = document.getElementById('comic-announcer');
|
||||
|
||||
switch(event.key) {
|
||||
case 'ArrowLeft':
|
||||
// Previous comic
|
||||
if (currentComicNumber > 1) {
|
||||
event.preventDefault();
|
||||
loadComic(currentComicNumber - 1);
|
||||
} else if (announcer) {
|
||||
announcer.textContent = 'Already at the first comic';
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
// Next comic
|
||||
if (currentComicNumber < totalComics) {
|
||||
event.preventDefault();
|
||||
loadComic(currentComicNumber + 1);
|
||||
} else if (announcer) {
|
||||
announcer.textContent = 'Already at the latest comic';
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
// First comic
|
||||
if (currentComicNumber > 1) {
|
||||
event.preventDefault();
|
||||
loadComic(1);
|
||||
} else if (announcer) {
|
||||
announcer.textContent = 'Already at the first comic';
|
||||
}
|
||||
break;
|
||||
case 'End':
|
||||
// Latest comic
|
||||
if (currentComicNumber < totalComics) {
|
||||
event.preventDefault();
|
||||
loadComic(totalComics);
|
||||
} else if (announcer) {
|
||||
announcer.textContent = 'Already at the latest comic';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,15 +428,35 @@
|
||||
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
|
||||
totalComics = parseInt(document.querySelector('.comic-container').dataset.totalComics || 0);
|
||||
|
||||
// Get current comic number
|
||||
const currentNumber = parseInt(document.querySelector('.comic-container').dataset.comicNumber || 0);
|
||||
currentComicNumber = currentNumber;
|
||||
|
||||
if (currentNumber && totalComics) {
|
||||
updateNavButtons(currentNumber);
|
||||
// Get the formatted date from the DOM (already rendered by server)
|
||||
const dateDisplay = document.querySelector('.comic-date-display');
|
||||
const formattedDate = dateDisplay ? dateDisplay.textContent : null;
|
||||
updateNavButtons(currentNumber, formattedDate);
|
||||
|
||||
// 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
|
||||
@@ -167,6 +466,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard navigation
|
||||
document.addEventListener('keydown', handleKeyboardNavigation);
|
||||
|
||||
// Set initial state
|
||||
history.replaceState({ comicId: currentNumber }, '', window.location.pathname);
|
||||
}
|
||||
|
||||
129
static/js/embed.js
Normal file
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,23 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>About Sunday Comics</h1>
|
||||
</div>
|
||||
|
||||
<section class="about-content">
|
||||
<p>Welcome to Sunday Comics, a webcomic about life, humor, and everything in between.</p>
|
||||
|
||||
<h2>About the Comic</h2>
|
||||
<p>Sunday Comics is updated regularly with new strips. Each comic tells a story, shares a laugh, or offers a unique perspective on everyday situations.</p>
|
||||
|
||||
<h2>About the Author</h2>
|
||||
<p>Sunday Comics is created by [Your Name]. [Add your bio here]</p>
|
||||
|
||||
<h2>Updates</h2>
|
||||
<p>New comics are posted [specify your schedule, e.g., "every Monday and Thursday" or "weekly"].</p>
|
||||
|
||||
<h2>Support</h2>
|
||||
<p>If you enjoy Sunday Comics, consider sharing it with friends or supporting the comic through [Patreon/Ko-fi/etc.].</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,26 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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>
|
||||
<p>Browse all {{ comics|length }} comics</p>
|
||||
<p>Browse all {{ total_comics }} comics</p>
|
||||
</div>
|
||||
|
||||
<section class="archive-content">
|
||||
<div class="archive-grid">
|
||||
{% for comic in comics %}
|
||||
<div class="archive-item">
|
||||
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}"
|
||||
data-total-comics="{{ total_comics }}"
|
||||
data-initial-batch="{{ initial_batch }}">
|
||||
{% for section_title, section_comics in sections %}
|
||||
{% if section_title and sections_enabled %}
|
||||
<div class="section-header">
|
||||
<h2>{{ section_title }}</h2>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="archive-grid{% if archive_full_width %} archive-grid-fullwidth{% endif %}">
|
||||
{% for comic in section_comics %}
|
||||
<div class="archive-item{% if archive_full_width %} archive-item-fullwidth{% endif %}">
|
||||
<a href="{{ url_for('comic', comic_id=comic.number) }}">
|
||||
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
|
||||
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}">
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
loading="lazy">
|
||||
<div class="archive-info">
|
||||
{% if not archive_full_width %}
|
||||
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
|
||||
{% endif %}
|
||||
<p class="archive-date">{{ comic.date }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% if archive_full_width %}
|
||||
<div class="container"> {# Reopen container for footer #}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/archive-lazy-load.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,17 +3,25 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 -->
|
||||
<meta name="description" content="{% block meta_description %}A webcomic about life, the universe, and everything{% endblock %}">
|
||||
<link rel="canonical" href="{% block canonical %}{{ site_url }}{{ request.path }}{% endblock %}">
|
||||
|
||||
<!-- Version -->
|
||||
<meta name="generator" content="Sunday Comics {{ version }}">
|
||||
|
||||
<!-- AI Scraping Prevention -->
|
||||
<meta name="robots" content="noai, noimageai">
|
||||
<meta name="googlebot" content="noai, noimageai">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{% block meta_url %}{{ request.url }}{% endblock %}">
|
||||
<meta property="og:title" content="{% block og_title %}{{ self.title() }} - Sunday Comics{% endblock %}">
|
||||
<meta property="og:url" content="{% block meta_url %}{{ site_url }}{{ request.path }}{% 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: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 -->
|
||||
<meta property="twitter:card" content="summary_large_image">
|
||||
@@ -29,45 +37,110 @@
|
||||
<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="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 %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<body data-site-url="{{ site_url }}">
|
||||
<!-- Skip to main content link for keyboard navigation -->
|
||||
<a href="#main-content" class="skip-to-main">Skip to main content</a>
|
||||
|
||||
{% if header_image %}
|
||||
<div class="site-header-image">
|
||||
<img src="{{ url_for('static', filename='images/' + header_image) }}" alt="{{ comic_name }} Header">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<header{% if header_image %} class="header-with-image"{% endif %}>
|
||||
<nav>
|
||||
<div class="container">
|
||||
{% if not header_image %}
|
||||
<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>
|
||||
{% endif %}
|
||||
<ul class="nav-links">
|
||||
<li><a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}>Latest</a></li>
|
||||
<li><a href="{{ url_for('archive') }}" {% if request.endpoint == 'archive' %}class="active"{% endif %}>Archive</a></li>
|
||||
<li><a href="{{ url_for('about') }}" {% if request.endpoint == 'about' %}class="active"{% endif %}>About</a></li>
|
||||
<li>
|
||||
<a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}>
|
||||
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/alert.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}Latest
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('archive') }}" {% if request.endpoint == 'archive' %}class="active"{% endif %}>
|
||||
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/archive.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}Archive
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('about') }}" {% if request.endpoint == 'about' %}class="active"{% endif %}>
|
||||
{% if use_header_nav_icons %}<img src="{{ url_for('static', filename='images/icons/info.png') }}" alt="" class="nav-icon" aria-hidden="true">{% endif %}About
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<main id="main-content">
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<footer{% if compact_footer %} class="compact-footer"{% endif %}>
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h3>Follow</h3>
|
||||
<div class="social-links">
|
||||
<!-- Uncomment and update with your social links -->
|
||||
<!-- <a href="https://twitter.com/yourhandle" target="_blank">Twitter</a> -->
|
||||
<!-- <a href="https://instagram.com/yourhandle" target="_blank">Instagram</a> -->
|
||||
<!-- <a href="https://mastodon.social/@yourhandle" target="_blank">Mastodon</a> -->
|
||||
<a href="{{ url_for('static', filename='feed.rss') }}">RSS Feed</a>
|
||||
<div class="social-links{% if use_footer_social_icons %} social-links-icons{% endif %}">
|
||||
{% if social_instagram %}
|
||||
<a href="{{ social_instagram }}" target="_blank" rel="noopener noreferrer" aria-label="Instagram">
|
||||
{% if use_footer_social_icons %}
|
||||
<img src="{{ url_for('static', filename='images/icons/instagram.png') }}" alt="" class="social-icon">
|
||||
{% else %}
|
||||
Instagram
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if social_youtube %}
|
||||
<a href="{{ social_youtube }}" target="_blank" rel="noopener noreferrer" aria-label="YouTube">
|
||||
{% if use_footer_social_icons %}
|
||||
<img src="{{ url_for('static', filename='images/icons/youtube.png') }}" alt="" class="social-icon">
|
||||
{% else %}
|
||||
YouTube
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if social_email %}
|
||||
<a href="{{ social_email }}" aria-label="Email">
|
||||
{% if use_footer_social_icons %}
|
||||
<img src="{{ url_for('static', filename='images/icons/mail .png') }}" alt="" class="social-icon">
|
||||
{% else %}
|
||||
Email
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('static', filename='feed.rss') }}" aria-label="RSS Feed">
|
||||
{% if use_footer_social_icons %}
|
||||
<img src="{{ url_for('static', filename='images/icons/rss.png') }}" alt="" class="social-icon">
|
||||
{% else %}
|
||||
RSS Feed
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if api_spec_link %}
|
||||
<a href="{{ url_for('static', filename=api_spec_link) }}" aria-label="API">API</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if newsletter_enabled %}
|
||||
<div class="footer-section">
|
||||
<h3>Newsletter</h3>
|
||||
<!-- Replace with your newsletter service form -->
|
||||
@@ -77,15 +150,69 @@
|
||||
</form> -->
|
||||
<p class="newsletter-placeholder">Newsletter coming soon!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if banner_image and not compact_footer %}
|
||||
<div class="footer-section">
|
||||
<h3>Share A Link</h3>
|
||||
<div class="shareable-banner">
|
||||
<a href="{{ site_url }}" target="_blank" rel="noopener noreferrer" aria-label="Link to {{ comic_name }} home page">
|
||||
<img src="{{ url_for('static', filename='images/' + banner_image) }}" alt="{{ comic_name }}" class="banner-image">
|
||||
</a>
|
||||
<details class="banner-code">
|
||||
<summary>Get code</summary>
|
||||
<textarea readonly onfocus="this.select()" onclick="this.select()" aria-label="HTML code for linking to {{ comic_name }}"><a href="{{ site_url }}"><img src="{{ site_url }}/static/images/{{ banner_image }}" alt="{{ comic_name }}"></a></textarea>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p>© 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>
|
||||
</footer>
|
||||
|
||||
{% if footer_image %}
|
||||
<div class="site-footer-image">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/comic-nav.js') }}"></script>
|
||||
{% if embed_enabled %}
|
||||
<script src="{{ url_for('static', filename='js/embed.js') }}"></script>
|
||||
{% endif %}
|
||||
{% if permalink_enabled %}
|
||||
<script src="{{ url_for('static', filename='js/permalink.js') }}"></script>
|
||||
{% endif %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,55 +2,170 @@
|
||||
|
||||
{% 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 %}
|
||||
<div class="comic-container" data-comic-number="{{ comic.number }}" data-total-comics="{{ total_comics }}">
|
||||
<!-- ARIA live region for screen reader announcements -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only" id="comic-announcer"></div>
|
||||
|
||||
<div class="comic-container{% if comic.full_width %} comic-container-fullwidth{% endif %}{% if comic.plain %} comic-container-plain{% endif %}" data-comic-number="{{ comic.number }}" data-total-comics="{{ total_comics }}">
|
||||
{% if not comic.plain %}
|
||||
<div class="comic-header">
|
||||
<h1>{{ comic.title if comic.title else '#' ~ comic.number }}</h1>
|
||||
<p class="comic-date">{{ comic.date }}</p>
|
||||
</div>
|
||||
{% 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.is_multi_image %}
|
||||
{# Multi-image layout (webtoon style) - no click-through on individual images #}
|
||||
{% for i in range(comic.filenames|length) %}
|
||||
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
|
||||
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
|
||||
alt="{{ comic.alt_texts[i] }}"
|
||||
title="{{ comic.alt_texts[i] }}"
|
||||
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Single image with click-through to next comic #}
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}">
|
||||
<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 }}">
|
||||
</a>
|
||||
</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 %}
|
||||
</a>
|
||||
{% else %}
|
||||
{% if comic.mobile_filename %}
|
||||
<picture>
|
||||
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
</picture>
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}"
|
||||
loading="eager">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="comic-navigation">
|
||||
<div class="nav-buttons">
|
||||
{% if use_comic_nav_icons %}
|
||||
{# Icon-based navigation #}
|
||||
{% if comic.number > 1 %}
|
||||
<a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First">
|
||||
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
|
||||
</a>
|
||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
|
||||
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true">
|
||||
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
|
||||
</span>
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true">
|
||||
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="comic-date-display">{{ comic.formatted_date }}</span>
|
||||
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
|
||||
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
|
||||
</a>
|
||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
|
||||
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true">
|
||||
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
|
||||
</span>
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true">
|
||||
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Text-based navigation #}
|
||||
{% if comic.number > 1 %}
|
||||
<a href="{{ url_for('comic', comic_id=1) }}" class="btn btn-nav">First</a>
|
||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn btn-nav">Previous</a>
|
||||
{% else %}
|
||||
<span class="btn btn-nav btn-disabled">First</span>
|
||||
<span class="btn btn-nav btn-disabled">Previous</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">First</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">Previous</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="comic-number">Comic #{{ comic.number }}</span>
|
||||
<span class="comic-date-display">{{ comic.formatted_date }}</span>
|
||||
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn btn-nav">Next</a>
|
||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn btn-nav">Latest</a>
|
||||
{% else %}
|
||||
<span class="btn btn-nav btn-disabled">Next</span>
|
||||
<span class="btn btn-nav btn-disabled">Latest</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">Next</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">Latest</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if embed_enabled or permalink_enabled %}
|
||||
<div class="comic-share-section">
|
||||
{% if permalink_enabled %}
|
||||
<button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic">
|
||||
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/link.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if embed_enabled %}
|
||||
<button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic">
|
||||
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/embed.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if comic.author_note %}
|
||||
<div class="comic-transcript">
|
||||
<h3>Author Note</h3>
|
||||
{% if comic.author_note_is_html == True %}
|
||||
<div>{{ comic.author_note|safe }}</div>
|
||||
{% else %}
|
||||
<p>{{ comic.author_note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
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,48 +3,128 @@
|
||||
{% if comic %}
|
||||
{% 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 %}
|
||||
|
||||
{% block content %}
|
||||
<!-- ARIA live region for screen reader announcements -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only" id="comic-announcer"></div>
|
||||
|
||||
<div class="comic-container" data-comic-number="{{ comic.number }}" data-total-comics="{{ total_comics }}">
|
||||
<div class="comic-header">
|
||||
<h1>{{ comic.title if comic.title else '#' ~ comic.number }}</h1>
|
||||
<p class="comic-date">{{ comic.date }}</p>
|
||||
</div>
|
||||
|
||||
<div class="comic-image">
|
||||
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
|
||||
{% if comic.is_multi_image %}
|
||||
{# Multi-image layout (webtoon style) - no click-through on individual images #}
|
||||
{% for i in range(comic.filenames|length) %}
|
||||
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
|
||||
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
|
||||
alt="{{ comic.alt_texts[i] }}"
|
||||
title="{{ comic.alt_texts[i] }}"
|
||||
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if comic.mobile_filename %}
|
||||
<picture>
|
||||
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
</picture>
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="comic-navigation">
|
||||
<div class="nav-buttons">
|
||||
{% if use_comic_nav_icons %}
|
||||
{# Icon-based navigation #}
|
||||
{% if comic.number > 1 %}
|
||||
<a href="{{ url_for('comic', comic_id=1) }}" class="btn-icon-nav" aria-label="First">
|
||||
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
|
||||
</a>
|
||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
|
||||
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true">
|
||||
<img src="{{ url_for('static', filename='images/icons/first.png') }}" alt="">
|
||||
</span>
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true">
|
||||
<img src="{{ url_for('static', filename='images/icons/previous.png') }}" alt="">
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="comic-date-display">{{ comic.formatted_date }}</span>
|
||||
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
|
||||
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
|
||||
</a>
|
||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
|
||||
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true">
|
||||
<img src="{{ url_for('static', filename='images/icons/next.png') }}" alt="">
|
||||
</span>
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true">
|
||||
<img src="{{ url_for('static', filename='images/icons/latest.png') }}" alt="">
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Text-based navigation #}
|
||||
{% if comic.number > 1 %}
|
||||
<a href="{{ url_for('comic', comic_id=1) }}" class="btn btn-nav">First</a>
|
||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn btn-nav">Previous</a>
|
||||
{% else %}
|
||||
<span class="btn btn-nav btn-disabled">First</span>
|
||||
<span class="btn btn-nav btn-disabled">Previous</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">First</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">Previous</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="comic-number">Comic #{{ comic.number }}</span>
|
||||
<span class="comic-date-display">{{ comic.formatted_date }}</span>
|
||||
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn btn-nav">Next</a>
|
||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn btn-nav">Latest</a>
|
||||
{% else %}
|
||||
<span class="btn btn-nav btn-disabled">Next</span>
|
||||
<span class="btn btn-nav btn-disabled">Latest</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">Next</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">Latest</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if embed_enabled or permalink_enabled %}
|
||||
<div class="comic-share-section">
|
||||
{% if permalink_enabled %}
|
||||
<button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic">
|
||||
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/link.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if embed_enabled %}
|
||||
<button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic">
|
||||
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/embed.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if comic.author_note %}
|
||||
<div class="comic-transcript">
|
||||
<h3>Author Note</h3>
|
||||
{% if comic.author_note_is_html == True %}
|
||||
<div>{{ comic.author_note|safe }}</div>
|
||||
{% else %}
|
||||
<p>{{ comic.author_note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
7
templates/page.html
Normal file
7
templates/page.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="about-content">
|
||||
{{ content|safe }}
|
||||
</section>
|
||||
{% endblock %}
|
||||
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