Compare commits
83 Commits
b6f6ee4b70
...
v2025.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8f30ef82 | |||
| 882eed90f9 | |||
| d374df6b0b | |||
| 9c566bcc3c | |||
| 2580ea076d | |||
| b1f0f9f09b | |||
| 0381908610 | |||
| 6b3d446207 | |||
| 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 |
31
.gitignore
vendored
31
.gitignore
vendored
@@ -1,5 +1,34 @@
|
||||
# ============================================================
|
||||
# FORK-FRIENDLY GITIGNORE
|
||||
# ============================================================
|
||||
# This gitignore is designed for the fork-and-customize workflow.
|
||||
# - User content (comics, images) IS tracked in git
|
||||
# - User config (comics_data.py, variables.css) IS tracked in git
|
||||
# - Only generated/temporary files are ignored
|
||||
# ============================================================
|
||||
|
||||
# IDE and environment
|
||||
.idea
|
||||
.venv
|
||||
|
||||
# This should be generated on deploy
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# User configuration (use the .example files as templates)
|
||||
# Uncomment these lines if you want to gitignore user config:
|
||||
# comics_data.py
|
||||
# static/css/variables.css
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Generated files (regenerated on deploy/publish)
|
||||
static/feed.rss
|
||||
static/sitemap.xml
|
||||
data/comics/.comics_cache.pkl
|
||||
43
CHANGELOG.md
Normal file
43
CHANGELOG.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# 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.18] - 2025-11-18
|
||||
|
||||
### Added
|
||||
- HTML embed support for comics (`html_embed` field in YAML allows custom HTML instead of images)
|
||||
- Configurable social links in footer (`SOCIAL_LINKS` in `comics_data.py`)
|
||||
- Customizable newsletter section (`NEWSLETTER_ENABLED` and `NEWSLETTER_HTML` in `comics_data.py`)
|
||||
- CDN option for serving static assets
|
||||
- Upstream update workflow (`UPSTREAM.md`) for fork-friendly development
|
||||
- Automatic cache building, RSS feed, and sitemap generation in Docker container
|
||||
|
||||
### Changed
|
||||
- Footer social links now fully customizable with any platform (Instagram, YouTube, Patreon, etc.)
|
||||
|
||||
## [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.18...HEAD
|
||||
[2025.11.18]: https://git.puercito.net/mi/sunday/compare/v2025.11.15...v2025.11.18
|
||||
[2025.11.15]: https://git.puercito.net/mi/sunday/releases/tag/v2025.11.15
|
||||
445
CLAUDE.md
Normal file
445
CLAUDE.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# 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.
|
||||
|
||||
## Fork-and-Customize Architecture
|
||||
|
||||
**IMPORTANT:** Sunday Comics is designed for users to fork and customize for their own webcomics. When making changes, maintain the separation between framework code and user customization to avoid breaking upstream updates.
|
||||
|
||||
### File Categories
|
||||
|
||||
**Core Framework Files** (Updated by upstream - DO NOT modify unless fixing bugs):
|
||||
- `app.py` - Flask application logic
|
||||
- `data_loader.py` - YAML loading and caching
|
||||
- `templates/*.html` - Jinja2 templates
|
||||
- `static/css/style.css` - Core framework styles (references CSS variables)
|
||||
- `static/js/*.js` - Client-side navigation and functionality
|
||||
- `scripts/*.py` - Utility scripts
|
||||
- `version.py`, `VERSION` - Version management
|
||||
- `Dockerfile`, `docker-compose.yml` - Deployment configuration
|
||||
|
||||
**User Customization Files** (Safe for users to modify):
|
||||
- `comics_data.py` - Global configuration settings
|
||||
- `static/css/variables.css` - Design variables (colors, fonts, spacing, layout)
|
||||
- `data/comics/*.yaml` - Comic metadata (except TEMPLATE.yaml)
|
||||
- `content/*.md` - Markdown content (about page, author notes, terms)
|
||||
- `static/images/*` - User's images and graphics
|
||||
|
||||
**Template Files** (Reference only):
|
||||
- `comics_data.py.example` - Configuration template showing all options
|
||||
- `data/comics/TEMPLATE.yaml` - Comic file template
|
||||
|
||||
**Generated Files** (Auto-created, gitignored):
|
||||
- `static/feed.rss`, `static/sitemap.xml` - Generated by scripts
|
||||
- `data/comics/.comics_cache.pkl` - Comic cache
|
||||
- `__pycache__/`, `*.pyc` - Python bytecode
|
||||
|
||||
### CSS Architecture
|
||||
|
||||
**Two-file CSS system** to separate customization from framework:
|
||||
|
||||
1. **`static/css/variables.css`** (User customization)
|
||||
- Contains all CSS custom properties
|
||||
- Organized by category: Colors, Typography, Spacing, Borders, Layout, Transitions
|
||||
- Users edit this file to customize their design
|
||||
- Loaded first in templates
|
||||
|
||||
2. **`static/css/style.css`** (Core framework)
|
||||
- References variables from variables.css
|
||||
- Contains structural styles and layout logic
|
||||
- Should not be modified by users (to allow upstream updates)
|
||||
- Loaded after variables.css
|
||||
|
||||
**When modifying styles:**
|
||||
- Add new design tokens to `variables.css` (e.g., `--color-accent: #ff0000;`)
|
||||
- Reference those variables in `style.css` (e.g., `color: var(--color-accent);`)
|
||||
- Never hardcode values in `style.css` that users might want to customize
|
||||
|
||||
### Configuration Pattern
|
||||
|
||||
**`comics_data.py.example`** serves as a reference:
|
||||
- Shows all available configuration options with defaults
|
||||
- Updated when new settings are added to the framework
|
||||
- Users can check this file when merging upstream updates
|
||||
- Never imported - purely documentation
|
||||
|
||||
**When adding new configuration options:**
|
||||
1. Add the option to `comics_data.py` with a default value
|
||||
2. Add the same option to `comics_data.py.example` with documentation
|
||||
3. Update `CHANGELOG.md` with migration instructions
|
||||
4. Consider backward compatibility (provide sensible defaults)
|
||||
|
||||
### Best Practices for Code Changes
|
||||
|
||||
**DO:**
|
||||
- ✅ Add new features to core framework files (app.py, templates, scripts)
|
||||
- ✅ Create new CSS variables in variables.css for customizable values
|
||||
- ✅ Update comics_data.py.example when adding new config options
|
||||
- ✅ Document breaking changes in CHANGELOG.md with migration steps
|
||||
- ✅ Test that user customizations (comics_data.py, variables.css) still work
|
||||
- ✅ Keep file structure consistent with the fork-friendly model
|
||||
|
||||
**DON'T:**
|
||||
- ❌ Hardcode design values in style.css that users might want to change
|
||||
- ❌ Modify user content files (data/comics/*.yaml, content/*.md)
|
||||
- ❌ Change the purpose or structure of user customization files
|
||||
- ❌ Remove configuration options without deprecation warnings
|
||||
- ❌ Make changes that require users to edit core framework files
|
||||
|
||||
**When adding new features:**
|
||||
1. Ask: "Will users want to customize this?"
|
||||
2. If yes: Add a variable/config option
|
||||
3. If no: Implement in framework code
|
||||
4. Always maintain the separation
|
||||
|
||||
### Upstream Update Workflow
|
||||
|
||||
Users following [UPSTREAM.md](UPSTREAM.md) will:
|
||||
1. Fork the repository
|
||||
2. Customize `comics_data.py` and `variables.css`
|
||||
3. Add their comics and content
|
||||
4. Periodically merge upstream updates: `git merge upstream/main`
|
||||
5. Resolve conflicts (usually only in .example files)
|
||||
6. Benefit from framework improvements without losing customizations
|
||||
|
||||
## 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 unless using html_embed): Image filename in `static/images/comics/` OR list of filenames for multi-image comics (webtoon style)
|
||||
- `html_embed` (optional): Custom HTML to embed instead of an image (e.g., video player, widget). Takes precedence over `filename`.
|
||||
- `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.
|
||||
|
||||
**HTML embeds in YAML:**
|
||||
```yaml
|
||||
html_embed: '<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" frameborder="0" allowfullscreen></iframe>'
|
||||
alt_text: "Video description for accessibility"
|
||||
```
|
||||
- Use `html_embed` to display custom HTML content instead of an image
|
||||
- Useful for embedding videos, interactive widgets, or special content
|
||||
- When `html_embed` is present, it takes precedence over `filename` and `mobile_filename`
|
||||
- The HTML is rendered as-is using the `| safe` filter in templates
|
||||
- No click-through navigation on HTML embeds (use navigation buttons instead)
|
||||
- Still provide `alt_text` for accessibility context
|
||||
|
||||
**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
|
||||
- `NEWSLETTER_HTML`: Custom HTML for newsletter form (user pastes their service's form code here)
|
||||
- `SOCIAL_LINKS`: List of dicts for social media links. Each dict has 'label', 'url', and optional 'icon' (filename in static/images/icons/). Users can add any platform (Instagram, YouTube, Bluesky, Patreon, etc.)
|
||||
|
||||
### 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 (disabled for HTML embeds and multi-image comics)
|
||||
- Renders HTML embeds, multi-image comics, and single-image comics dynamically
|
||||
- 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`
|
||||
- `newsletter_html`: Custom HTML for newsletter form from `comics_data.py` (rendered with `| safe` filter)
|
||||
- `social_links`: List of social media link dicts from `comics_data.py` (each with 'label', 'url', 'icon')
|
||||
|
||||
## Static Assets
|
||||
|
||||
- `static/css/variables.css`: Design variables for user customization (colors, fonts, spacing, etc.)
|
||||
- `static/css/style.css`: Core framework styles (references variables.css)
|
||||
- `static/js/comic-nav.js`: Client-side navigation
|
||||
- `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 (colors defined in `static/css/variables.css`, styles 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
|
||||
@@ -25,6 +25,9 @@ COPY --chown=appuser:appuser . .
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Generate cache, RSS feed, and sitemap during build
|
||||
RUN python scripts/publish_comic.py
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
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.
|
||||
356
UPSTREAM.md
Normal file
356
UPSTREAM.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# Updating from Upstream
|
||||
|
||||
This guide explains how to keep your forked Sunday Comics site up-to-date with framework improvements while preserving your customizations.
|
||||
|
||||
## Fork-and-Customize Workflow
|
||||
|
||||
Sunday Comics is designed to be forked and customized for your own webcomic. The project separates:
|
||||
|
||||
- **Core framework files** (updated by upstream) - app logic, templates, scripts
|
||||
- **User content files** (your comic data) - comics, images, markdown content
|
||||
- **User configuration** (your settings) - config and design variables
|
||||
|
||||
When you pull updates from upstream, you'll get new features and bug fixes without losing your content or customizations.
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### 1. Fork the Repository
|
||||
|
||||
On GitHub, click "Fork" to create your own copy of Sunday Comics.
|
||||
|
||||
### 2. Clone Your Fork
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/sunday-comics.git
|
||||
cd sunday-comics
|
||||
```
|
||||
|
||||
### 3. Add Upstream Remote
|
||||
|
||||
Add the original repository as an "upstream" remote:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/ORIGINAL-AUTHOR/sunday-comics.git
|
||||
```
|
||||
|
||||
Verify your remotes:
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
# origin https://github.com/YOUR-USERNAME/sunday-comics.git (fetch)
|
||||
# origin https://github.com/YOUR-USERNAME/sunday-comics.git (push)
|
||||
# upstream https://github.com/ORIGINAL-AUTHOR/sunday-comics.git (fetch)
|
||||
# upstream https://github.com/ORIGINAL-AUTHOR/sunday-comics.git (push)
|
||||
```
|
||||
|
||||
### 4. Set Up Your Configuration
|
||||
|
||||
**Option A: Track your config in git (recommended)**
|
||||
|
||||
Your `comics_data.py` and `static/css/variables.css` files are already set up. Customize them and commit:
|
||||
|
||||
```bash
|
||||
# Edit your configuration
|
||||
nano comics_data.py
|
||||
nano static/css/variables.css
|
||||
|
||||
# Commit your changes
|
||||
git add comics_data.py static/css/variables.css
|
||||
git commit -m "Configure site settings and design"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**Option B: Keep config out of git**
|
||||
|
||||
If you prefer to keep your config private:
|
||||
|
||||
```bash
|
||||
# Uncomment the gitignore lines for config files
|
||||
nano .gitignore
|
||||
# Uncomment:
|
||||
# comics_data.py
|
||||
# static/css/variables.css
|
||||
|
||||
# Copy the example files
|
||||
cp comics_data.py comics_data.py.backup
|
||||
# Now comics_data.py won't be tracked
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Updates from Upstream
|
||||
|
||||
### 1. Fetch Upstream Changes
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
```
|
||||
|
||||
This downloads new commits from the original repository without modifying your files.
|
||||
|
||||
### 2. Review What Changed
|
||||
|
||||
See what's new in upstream:
|
||||
|
||||
```bash
|
||||
# View commit log
|
||||
git log HEAD..upstream/main --oneline
|
||||
|
||||
# See which files changed
|
||||
git diff HEAD..upstream/main --stat
|
||||
|
||||
# Review the CHANGELOG
|
||||
git show upstream/main:CHANGELOG.md
|
||||
```
|
||||
|
||||
### 3. Merge Upstream Changes
|
||||
|
||||
```bash
|
||||
# Merge upstream into your current branch
|
||||
git merge upstream/main
|
||||
```
|
||||
|
||||
**If there are no conflicts:**
|
||||
```bash
|
||||
# Push the updates to your fork
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**If there are conflicts** (see next section).
|
||||
|
||||
---
|
||||
|
||||
## Handling Merge Conflicts
|
||||
|
||||
Conflicts may occur if both you and upstream modified the same file. Common scenarios:
|
||||
|
||||
### Scenario 1: Framework Added New Config Options
|
||||
|
||||
**Example:** Upstream added a new setting to `comics_data.py.example`
|
||||
|
||||
**What to do:**
|
||||
1. The merge conflict will only be in `comics_data.py.example` (not your `comics_data.py`)
|
||||
2. Accept upstream's version:
|
||||
```bash
|
||||
git checkout --theirs comics_data.py.example
|
||||
git add comics_data.py.example
|
||||
```
|
||||
3. Review `comics_data.py.example` for new settings
|
||||
4. Manually add any new settings you want to your `comics_data.py`
|
||||
|
||||
### Scenario 2: CSS Variables Conflict
|
||||
|
||||
**Example:** Both you and upstream added/changed CSS variables
|
||||
|
||||
**What to do:**
|
||||
1. Open the conflicted file:
|
||||
```bash
|
||||
nano static/css/variables.css
|
||||
```
|
||||
2. Look for conflict markers:
|
||||
```
|
||||
<<<<<<< HEAD
|
||||
--your-custom-variable: #ff0000;
|
||||
=======
|
||||
--new-upstream-variable: #00ff00;
|
||||
>>>>>>> upstream/main
|
||||
```
|
||||
3. Keep both variables:
|
||||
```css
|
||||
--your-custom-variable: #ff0000;
|
||||
--new-upstream-variable: #00ff00;
|
||||
```
|
||||
4. Save and mark as resolved:
|
||||
```bash
|
||||
git add static/css/variables.css
|
||||
git commit -m "Merge upstream CSS variables"
|
||||
```
|
||||
|
||||
### Scenario 3: Rare - Core File Conflicts
|
||||
|
||||
If you modified a core framework file (app.py, templates, etc.), you may have conflicts.
|
||||
|
||||
**Best practice:** Avoid modifying core files. If you need custom behavior:
|
||||
- Add new routes/functions instead of modifying existing ones
|
||||
- Use template blocks for customization
|
||||
- Open an issue/PR to suggest the feature for upstream
|
||||
|
||||
**If you must resolve:**
|
||||
```bash
|
||||
# Open the conflicted file
|
||||
nano path/to/conflicted_file.py
|
||||
|
||||
# Resolve conflicts manually
|
||||
# Then:
|
||||
git add path/to/conflicted_file.py
|
||||
git commit -m "Merge upstream changes with custom modifications"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Categories Reference
|
||||
|
||||
### Core Framework (Upstream Updates)
|
||||
|
||||
**DO NOT modify these files** - you'll get conflicts when merging:
|
||||
|
||||
- `app.py` - Flask application
|
||||
- `data_loader.py` - YAML loading logic
|
||||
- `scripts/*.py` - Utility scripts
|
||||
- `templates/*.html` - Jinja templates (unless extending)
|
||||
- `static/css/style.css` - Core CSS framework
|
||||
- `static/js/*.js` - JavaScript functionality
|
||||
- `version.py`, `VERSION` - Version info
|
||||
- `Dockerfile`, `docker-compose.yml` - Deployment config
|
||||
- Documentation files (README, CLAUDE, etc.)
|
||||
|
||||
### User Configuration (You Customize)
|
||||
|
||||
**Safe to modify** - tracked in git:
|
||||
|
||||
- `comics_data.py` - Your site settings
|
||||
- `static/css/variables.css` - Your design variables
|
||||
- `data/comics/*.yaml` - Your comic metadata
|
||||
- `content/*.md` - Your markdown content (about page, author notes)
|
||||
- `static/images/*` - Your images and graphics
|
||||
|
||||
### Template Files (Reference)
|
||||
|
||||
**Use as reference** - copy to create your versions:
|
||||
|
||||
- `comics_data.py.example` - Default configuration template
|
||||
- `data/comics/TEMPLATE.yaml` - Comic file template
|
||||
|
||||
### Generated Files (Ignored by Git)
|
||||
|
||||
**Automatically regenerated:**
|
||||
|
||||
- `static/feed.rss` - RSS feed
|
||||
- `static/sitemap.xml` - Sitemap
|
||||
- `data/comics/.comics_cache.pkl` - Comic cache
|
||||
- `__pycache__/` - Python bytecode
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
When upstream releases breaking changes, check `CHANGELOG.md` for migration instructions.
|
||||
|
||||
**Example migration scenario:**
|
||||
|
||||
```markdown
|
||||
## [2025.03.15] - Breaking Changes
|
||||
### Changed
|
||||
- Renamed `SHOW_HEADER_IMAGE` to `HEADER_IMAGE` in comics_data.py
|
||||
|
||||
**Migration:** Update your `comics_data.py`:
|
||||
- Old: `SHOW_HEADER_IMAGE = True`
|
||||
- New: `HEADER_IMAGE = 'title.jpg'`
|
||||
```
|
||||
|
||||
After merging, check the CHANGELOG and update your config accordingly.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO:
|
||||
|
||||
- Keep your fork's main branch in sync with upstream
|
||||
- Customize via `comics_data.py` and `variables.css`
|
||||
- Add your comics to `data/comics/`
|
||||
- Read the CHANGELOG before/after merging
|
||||
- Test your site after merging updates
|
||||
- Commit your changes regularly
|
||||
|
||||
### ❌ DON'T:
|
||||
|
||||
- Modify core framework files (app.py, templates, etc.)
|
||||
- Delete or rename upstream files (you'll break updates)
|
||||
- Force push over merged commits
|
||||
- Ignore merge conflicts (resolve them properly)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "I modified a core file and now I have conflicts"
|
||||
|
||||
**Option 1: Keep your changes** (advanced)
|
||||
```bash
|
||||
# Resolve conflicts manually, testing thoroughly
|
||||
git mergetool
|
||||
git commit
|
||||
```
|
||||
|
||||
**Option 2: Discard your changes** (start fresh)
|
||||
```bash
|
||||
# Reset to upstream version
|
||||
git checkout upstream/main -- path/to/file.py
|
||||
git add path/to/file.py
|
||||
git commit -m "Reset to upstream version"
|
||||
```
|
||||
|
||||
### "I want to see what changed before merging"
|
||||
|
||||
```bash
|
||||
# Create a test branch
|
||||
git checkout -b test-upstream-merge
|
||||
|
||||
# Merge in the test branch
|
||||
git merge upstream/main
|
||||
|
||||
# If you like it, merge to main
|
||||
git checkout main
|
||||
git merge test-upstream-merge
|
||||
|
||||
# If not, delete the test branch
|
||||
git checkout main
|
||||
git branch -D test-upstream-merge
|
||||
```
|
||||
|
||||
### "My site broke after merging"
|
||||
|
||||
```bash
|
||||
# Check what changed
|
||||
git log --oneline -10
|
||||
|
||||
# Test the previous version
|
||||
git checkout HEAD~1
|
||||
python app.py
|
||||
|
||||
# If it works, the issue is in the latest commit
|
||||
git checkout main
|
||||
git diff HEAD~1 HEAD
|
||||
|
||||
# Read CHANGELOG for migration notes
|
||||
cat CHANGELOG.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Check the docs:** `README.md`, `CLAUDE.md`, `CHANGELOG.md`
|
||||
- **Report issues:** [GitHub Issues](https://github.com/ORIGINAL-AUTHOR/sunday-comics/issues)
|
||||
- **Ask questions:** Open a discussion or issue on GitHub
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Regular update workflow:**
|
||||
|
||||
```bash
|
||||
# Every few weeks/months:
|
||||
git fetch upstream # Get new commits
|
||||
git log HEAD..upstream/main --oneline # Review changes
|
||||
git merge upstream/main # Merge updates
|
||||
# Resolve any conflicts
|
||||
git push origin main # Push to your fork
|
||||
python scripts/publish_comic.py # Rebuild site
|
||||
```
|
||||
|
||||
Your comics, images, and config stay safe - only the framework code updates!
|
||||
435
app.py
435
app.py
@@ -1,6 +1,24 @@
|
||||
# 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, CDN_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, NEWSLETTER_HTML,
|
||||
SOCIAL_LINKS, 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 +26,192 @@ 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.template_filter('cdn_static')
|
||||
def cdn_static(filename):
|
||||
"""Generate URL for static assets with CDN support
|
||||
|
||||
When CDN_URL is set, returns CDN URL. Otherwise returns local static URL.
|
||||
|
||||
Args:
|
||||
filename: Path to static file (e.g., 'css/style.css')
|
||||
|
||||
Returns:
|
||||
Full URL to the static asset
|
||||
"""
|
||||
from flask import url_for
|
||||
if CDN_URL:
|
||||
return f"{CDN_URL}/static/{filename}"
|
||||
return url_for('static', filename=filename)
|
||||
|
||||
|
||||
@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,
|
||||
'cdn_url': CDN_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,
|
||||
'newsletter_html': NEWSLETTER_HTML,
|
||||
'social_links': SOCIAL_LINKS,
|
||||
'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 +232,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 +413,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"""
|
||||
|
||||
160
comics_data.py
160
comics_data.py
@@ -1,27 +1,139 @@
|
||||
# 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: CDN URL for static assets (set to None to use local assets)
|
||||
# When set, all static assets will be served from this CDN
|
||||
# Example: CDN_URL = 'https://cdn.example.com' (no trailing slash)
|
||||
# Leave as None for local development or if not using a CDN
|
||||
CDN_URL = None
|
||||
|
||||
# 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
|
||||
|
||||
# Global setting: Custom HTML for newsletter form (only used if NEWSLETTER_ENABLED is True)
|
||||
# Paste your newsletter service's form HTML here (Mailchimp, ConvertKit, Buttondown, etc.)
|
||||
# Example:
|
||||
# NEWSLETTER_HTML = '''
|
||||
# <form action="https://yourservice.com/subscribe" method="post" class="newsletter-form">
|
||||
# <input type="email" name="email" placeholder="Enter your email" required>
|
||||
# <button type="submit">Subscribe</button>
|
||||
# </form>
|
||||
# '''
|
||||
NEWSLETTER_HTML = '<p class="newsletter-placeholder">Newsletter coming soon!</p>'
|
||||
|
||||
# Social media links - add/remove/reorder as needed
|
||||
# Each link should have 'label', 'url', and optionally 'icon' (filename in static/images/icons/)
|
||||
# Set to empty list [] to show no social links
|
||||
SOCIAL_LINKS = [
|
||||
# Example links (uncomment and customize):
|
||||
# {'label': 'Instagram', 'url': 'https://instagram.com/yourhandle', 'icon': 'instagram.png'},
|
||||
# {'label': 'YouTube', 'url': 'https://youtube.com/@yourchannel', 'icon': 'youtube.png'},
|
||||
# {'label': 'Email', 'url': 'mailto:your@email.com', 'icon': 'mail.png'},
|
||||
# {'label': 'Bluesky', 'url': 'https://bsky.app/profile/yourhandle', 'icon': 'bluesky.png'},
|
||||
# {'label': 'Patreon', 'url': 'https://patreon.com/yourname', 'icon': 'patreon.png'},
|
||||
# {'label': 'Ko-fi', 'url': 'https://ko-fi.com/yourname', 'icon': 'kofi.png'},
|
||||
# {'label': 'Mastodon', 'url': 'https://mastodon.social/@yourhandle', 'icon': 'mastodon.png'},
|
||||
]
|
||||
|
||||
# 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/")
|
||||
|
||||
139
comics_data.py.example
Normal file
139
comics_data.py.example
Normal file
@@ -0,0 +1,139 @@
|
||||
# Sunday Comics - Comic data configuration
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
#
|
||||
# Edit this file to add, remove, or modify comics
|
||||
|
||||
# Global setting: The name of your comic/website
|
||||
COMIC_NAME = 'Sunday Comics'
|
||||
|
||||
# Global setting: The name to display in the copyright notice
|
||||
# If not set (None), defaults to COMIC_NAME
|
||||
COPYRIGHT_NAME = None # e.g., 'Your Name' or 'Your Studio Name'
|
||||
|
||||
# Global setting: Your website's domain (used for RSS feed, Open Graph tags, etc.)
|
||||
# Update this to your production domain when deploying
|
||||
SITE_URL = 'http://localhost:3000'
|
||||
|
||||
# Global setting: CDN URL for static assets (set to None to use local assets)
|
||||
# When set, all static assets will be served from this CDN
|
||||
# Example: CDN_URL = 'https://cdn.example.com' (no trailing slash)
|
||||
# Leave as None for local development or if not using a CDN
|
||||
CDN_URL = None
|
||||
|
||||
# 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
|
||||
|
||||
# Global setting: Custom HTML for newsletter form (only used if NEWSLETTER_ENABLED is True)
|
||||
# Paste your newsletter service's form HTML here (Mailchimp, ConvertKit, Buttondown, etc.)
|
||||
# Example:
|
||||
# NEWSLETTER_HTML = '''
|
||||
# <form action="https://yourservice.com/subscribe" method="post" class="newsletter-form">
|
||||
# <input type="email" name="email" placeholder="Enter your email" required>
|
||||
# <button type="submit">Subscribe</button>
|
||||
# </form>
|
||||
# '''
|
||||
NEWSLETTER_HTML = '<p class="newsletter-placeholder">Newsletter coming soon!</p>'
|
||||
|
||||
# Social media links - add/remove/reorder as needed
|
||||
# Each link should have 'label', 'url', and optionally 'icon' (filename in static/images/icons/)
|
||||
# Set to empty list [] to show no social links
|
||||
SOCIAL_LINKS = [
|
||||
# Example links (uncomment and customize):
|
||||
# {'label': 'Instagram', 'url': 'https://instagram.com/yourhandle', 'icon': 'instagram.png'},
|
||||
# {'label': 'YouTube', 'url': 'https://youtube.com/@yourchannel', 'icon': 'youtube.png'},
|
||||
# {'label': 'Email', 'url': 'mailto:your@email.com', 'icon': 'mail.png'},
|
||||
# {'label': 'Bluesky', 'url': 'https://bsky.app/profile/yourhandle', 'icon': 'bluesky.png'},
|
||||
# {'label': 'Patreon', 'url': 'https://patreon.com/yourname', 'icon': 'patreon.png'},
|
||||
# {'label': 'Ko-fi', 'url': 'https://ko-fi.com/yourname', 'icon': 'kofi.png'},
|
||||
# {'label': 'Mastodon', 'url': 'https://mastodon.social/@yourhandle', 'icon': 'mastodon.png'},
|
||||
]
|
||||
|
||||
# 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.
|
||||
57
data/comics/TEMPLATE.yaml
Normal file
57
data/comics/TEMPLATE.yaml
Normal file
@@ -0,0 +1,57 @@
|
||||
# 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 (unless using html_embed): 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
|
||||
|
||||
# Optional: HTML embed instead of image
|
||||
# Use this to embed videos, widgets, or other HTML content
|
||||
# When set, this takes precedence over filename/mobile_filename
|
||||
# Example: '<iframe src="https://www.youtube.com/embed/..." width="560" height="315"></iframe>'
|
||||
# html_embed: '<div>Your custom HTML here</div>'
|
||||
|
||||
# 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()
|
||||
1136
static/css/style.css
1136
static/css/style.css
File diff suppressed because it is too large
Load Diff
80
static/css/variables.css
Normal file
80
static/css/variables.css
Normal file
@@ -0,0 +1,80 @@
|
||||
/* ============================================================
|
||||
CSS VARIABLES FOR CUSTOMIZATION
|
||||
============================================================
|
||||
|
||||
This file contains all customizable design tokens for your webcomic.
|
||||
Edit these values to change colors, fonts, spacing, and layout dimensions.
|
||||
|
||||
The main style.css file references these variables, so you can update
|
||||
your design without touching the core framework styles.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* ========================================
|
||||
COLORS
|
||||
======================================== */
|
||||
|
||||
--color-primary: #000;
|
||||
--color-background: #fff;
|
||||
--color-text: #000;
|
||||
--color-text-muted: #666;
|
||||
--color-disabled: #999;
|
||||
--color-hover-bg: #f0f0f0;
|
||||
|
||||
/* ========================================
|
||||
TYPOGRAPHY
|
||||
======================================== */
|
||||
|
||||
--font-family: 'Courier New', Courier, monospace;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-xs: 0.7rem;
|
||||
--font-size-sm: 0.75rem;
|
||||
--font-size-md: 0.85rem;
|
||||
--font-size-lg: 0.9rem;
|
||||
--font-size-xl: 1.2rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 2rem;
|
||||
--font-size-4xl: 6rem;
|
||||
--line-height-base: 1.5;
|
||||
--line-height-tight: 1.3;
|
||||
--line-height-relaxed: 1.6;
|
||||
--letter-spacing-tight: 1px;
|
||||
--letter-spacing-wide: 2px;
|
||||
|
||||
/* ========================================
|
||||
SPACING
|
||||
======================================== */
|
||||
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
--space-3xl: 4rem;
|
||||
|
||||
/* ========================================
|
||||
BORDERS
|
||||
======================================== */
|
||||
|
||||
--border-width-thin: 2px;
|
||||
--border-width-thick: 3px;
|
||||
--border-color: var(--color-primary);
|
||||
--border-radius: 0; /* Can be changed for rounded corners */
|
||||
|
||||
/* ========================================
|
||||
LAYOUT
|
||||
======================================== */
|
||||
|
||||
--container-max-width: 900px;
|
||||
--content-max-width: 700px;
|
||||
--archive-grid-min: 180px;
|
||||
--archive-grid-min-mobile: 140px;
|
||||
--archive-thumbnail-height: 120px;
|
||||
|
||||
/* ========================================
|
||||
TRANSITIONS
|
||||
======================================== */
|
||||
|
||||
--transition-speed: 0.2s;
|
||||
}
|
||||
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,405 @@
|
||||
|
||||
// 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
|
||||
updateComicImageLink(comic.number);
|
||||
// Update or create/remove the link wrapper (only for single-image comics, not HTML embeds)
|
||||
if (!comic.is_multi_image && !comic.html_embed) {
|
||||
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 classes
|
||||
if (comic.html_embed) {
|
||||
comicImageDiv.classList.add('comic-image-embed');
|
||||
comicImageDiv.classList.remove('comic-image-multi');
|
||||
} else if (comic.is_multi_image) {
|
||||
comicImageDiv.classList.add('comic-image-multi');
|
||||
comicImageDiv.classList.remove('comic-image-embed');
|
||||
} else {
|
||||
comicImageDiv.classList.remove('comic-image-multi');
|
||||
comicImageDiv.classList.remove('comic-image-embed');
|
||||
}
|
||||
|
||||
// Create new content
|
||||
if (comic.html_embed) {
|
||||
// HTML embed (video, widget, etc.)
|
||||
const embedWrapper = document.createElement('div');
|
||||
embedWrapper.className = 'comic-embed-wrapper';
|
||||
embedWrapper.innerHTML = comic.html_embed;
|
||||
comicImageDiv.appendChild(embedWrapper);
|
||||
} else 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 +439,36 @@
|
||||
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);
|
||||
updateComicImageLink(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 or HTML embed
|
||||
const comicImageDiv = document.querySelector('.comic-image');
|
||||
const isMultiImage = comicImageDiv && comicImageDiv.classList.contains('comic-image-multi');
|
||||
const isHtmlEmbed = comicImageDiv && comicImageDiv.classList.contains('comic-image-embed');
|
||||
|
||||
if (!isMultiImage && !isHtmlEmbed) {
|
||||
updateComicImageLink(currentNumber);
|
||||
} else if (isMultiImage) {
|
||||
// Initialize lazy loading for multi-image comics on page load
|
||||
initLazyLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle browser back/forward
|
||||
@@ -167,6 +478,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">
|
||||
<a href="{{ url_for('comic', comic_id=comic.number) }}">
|
||||
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
|
||||
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}">
|
||||
<div class="archive-info">
|
||||
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
|
||||
<p class="archive-date">{{ comic.date }}</p>
|
||||
</div>
|
||||
</a>
|
||||
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}"
|
||||
data-total-comics="{{ total_comics }}"
|
||||
data-initial-batch="{{ initial_batch }}">
|
||||
{% for section_title, section_comics in sections %}
|
||||
{% if section_title and sections_enabled %}
|
||||
<div class="section-header">
|
||||
<h2>{{ section_title }}</h2>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="archive-grid{% if archive_full_width %} archive-grid-fullwidth{% endif %}">
|
||||
{% for comic in section_comics %}
|
||||
<div class="archive-item{% if archive_full_width %} archive-item-fullwidth{% endif %}">
|
||||
<a href="{{ url_for('comic', comic_id=comic.number) }}">
|
||||
<img src="{{ ('images/thumbs/' + comic.filename) | cdn_static }}"
|
||||
onerror="this.onerror=null; this.src='{{ 'images/thumbs/default.jpg' | cdn_static }}';"
|
||||
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="{{ 'js/archive-lazy-load.js' | cdn_static }}"></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">
|
||||
@@ -23,69 +31,168 @@
|
||||
<meta property="twitter:image" content="{% block twitter_image %}{{ self.og_image() }}{% endblock %}">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/x-icon" href="{{ 'favicon.ico' | cdn_static }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ 'favicon-32x32.png' | cdn_static }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ 'favicon-16x16.png' | cdn_static }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ 'apple-touch-icon.png' | cdn_static }}">
|
||||
|
||||
<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') }}">
|
||||
<!-- CSS Variables (user customization) loaded first -->
|
||||
<link rel="stylesheet" href="{{ 'css/variables.css' | cdn_static }}">
|
||||
<!-- Core framework styles (references variables) -->
|
||||
<link rel="stylesheet" href="{{ 'css/style.css' | cdn_static }}">
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ comic_name }} RSS Feed" href="{{ 'feed.rss' | cdn_static }}">
|
||||
{% 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="{{ ('images/' + header_image) | cdn_static }}" 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="{{ ('images/' + logo_image) | cdn_static }}" 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="{{ ('images/' + logo_image) | cdn_static }}" 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="{{ 'images/icons/alert.png' | cdn_static }}" 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="{{ 'images/icons/archive.png' | cdn_static }}" 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="{{ 'images/icons/info.png' | cdn_static }}" 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 %}">
|
||||
{% for link in social_links %}
|
||||
<a href="{{ link.url }}" {% if link.url.startswith('http') %}target="_blank" rel="noopener noreferrer"{% endif %} aria-label="{{ link.label }}">
|
||||
{% if use_footer_social_icons and link.icon %}
|
||||
<img src="{{ ('images/icons/' + link.icon) | cdn_static }}" alt="" class="social-icon">
|
||||
{% else %}
|
||||
{{ link.label }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a href="{{ 'feed.rss' | cdn_static }}" aria-label="RSS Feed">
|
||||
{% if use_footer_social_icons %}
|
||||
<img src="{{ 'images/icons/rss.png' | cdn_static }}" alt="" class="social-icon">
|
||||
{% else %}
|
||||
RSS Feed
|
||||
{% endif %}
|
||||
</a>
|
||||
{% if api_spec_link %}
|
||||
<a href="{{ api_spec_link | cdn_static }}" aria-label="API">API</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if newsletter_enabled %}
|
||||
<div class="footer-section">
|
||||
<h3>Newsletter</h3>
|
||||
<!-- Replace with your newsletter service form -->
|
||||
<!-- <form class="newsletter-form" action="#" method="post">
|
||||
<input type="email" name="email" placeholder="Enter your email" required>
|
||||
<button type="submit">Subscribe</button>
|
||||
</form> -->
|
||||
<p class="newsletter-placeholder">Newsletter coming soon!</p>
|
||||
{{ newsletter_html | safe }}
|
||||
</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="{{ ('images/' + banner_image) | cdn_static }}" 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="{% if cdn_url %}{{ cdn_url }}/static/images/{{ banner_image }}{% else %}{{ site_url }}/static/images/{{ banner_image }}{% endif %}" 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="{{ 'images/sunday.jpg' | cdn_static }}" alt="Sunday Comics" class="credit-image">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/comic-nav.js') }}"></script>
|
||||
{% if footer_image %}
|
||||
<div class="site-footer-image">
|
||||
<img src="{{ ('images/' + footer_image) | cdn_static }}" 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="{{ 'js/comic-nav.js' | cdn_static }}"></script>
|
||||
{% if embed_enabled %}
|
||||
<script src="{{ 'js/embed.js' | cdn_static }}"></script>
|
||||
{% endif %}
|
||||
{% if permalink_enabled %}
|
||||
<script src="{{ 'js/permalink.js' | cdn_static }}"></script>
|
||||
{% endif %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,55 +2,175 @@
|
||||
|
||||
{% 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 %}{% if cdn_url %}{{ cdn_url }}/static/images/thumbs/{{ comic.filename }}{% else %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endif %}{% 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": "{% if cdn_url %}{{ cdn_url }}/static/images/comics/{{ comic.filename }}{% else %}{{ site_url }}/static/images/comics/{{ comic.filename }}{% endif %}",
|
||||
"thumbnailUrl": "{% if cdn_url %}{{ cdn_url }}/static/images/thumbs/{{ comic.filename }}{% else %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endif %}",
|
||||
"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">
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}">
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
</a>
|
||||
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}{% if comic.html_embed %} comic-image-embed{% endif %}" id="comic-image-focus" tabindex="-1">
|
||||
{% if comic.html_embed %}
|
||||
{# HTML embed (video, widget, etc.) #}
|
||||
<div class="comic-embed-wrapper">
|
||||
{{ comic.html_embed | safe }}
|
||||
</div>
|
||||
{% elif 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 %}{{ ('images/comics/' + comic.filenames[i]) | cdn_static }}{% endif %}"
|
||||
{% if not loop.first %}data-src="{{ ('images/comics/' + comic.filenames[i]) | cdn_static }}" class="lazy-load"{% endif %}
|
||||
alt="{{ comic.alt_texts[i] }}"
|
||||
title="{{ comic.alt_texts[i] }}"
|
||||
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
{# Single image with click-through to next comic #}
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic">
|
||||
{% if comic.mobile_filename %}
|
||||
<picture>
|
||||
<source media="(max-width: 768px)" srcset="{{ ('images/comics/' + comic.mobile_filename) | cdn_static }}">
|
||||
<img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
</picture>
|
||||
{% else %}
|
||||
<img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
|
||||
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="{{ ('images/comics/' + comic.mobile_filename) | cdn_static }}">
|
||||
<img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
</picture>
|
||||
{% else %}
|
||||
<img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
|
||||
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 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>
|
||||
{% endif %}
|
||||
{% 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="{{ 'images/icons/first.png' | cdn_static }}" alt="">
|
||||
</a>
|
||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
|
||||
<img src="{{ 'images/icons/previous.png' | cdn_static }}" alt="">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true">
|
||||
<img src="{{ 'images/icons/first.png' | cdn_static }}" alt="">
|
||||
</span>
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true">
|
||||
<img src="{{ 'images/icons/previous.png' | cdn_static }}" alt="">
|
||||
</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>
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
|
||||
<img src="{{ 'images/icons/next.png' | cdn_static }}" alt="">
|
||||
</a>
|
||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
|
||||
<img src="{{ 'images/icons/latest.png' | cdn_static }}" alt="">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true">
|
||||
<img src="{{ 'images/icons/next.png' | cdn_static }}" alt="">
|
||||
</span>
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true">
|
||||
<img src="{{ 'images/icons/latest.png' | cdn_static }}" alt="">
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="btn btn-nav btn-disabled">Next</span>
|
||||
<span class="btn btn-nav btn-disabled">Latest</span>
|
||||
{# 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" aria-disabled="true">First</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">Previous</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="comic-date-display">{{ comic.formatted_date }}</span>
|
||||
|
||||
{% 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" 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="{{ 'images/icons/link.png' | cdn_static }}" 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="{{ 'images/icons/embed.png' | cdn_static }}" 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="{% if cdn_url %}{{ cdn_url }}/static/images/{{ logo_image }}{% else %}{{ site_url }}/static/images/{{ logo_image }}{% endif %}" 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="{{ ('images/comics/' + comic.mobile_filename) | cdn_static }}">
|
||||
<img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}"
|
||||
class="embed-image">
|
||||
</picture>
|
||||
{% else %}
|
||||
<img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
|
||||
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,133 @@
|
||||
{% 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 %}{% if cdn_url %}{{ cdn_url }}/static/images/thumbs/{{ comic.filename }}{% else %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endif %}{% 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">
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}{% if comic.html_embed %} comic-image-embed{% endif %}" id="comic-image-focus" tabindex="-1">
|
||||
{% if comic.html_embed %}
|
||||
{# HTML embed (video, widget, etc.) #}
|
||||
<div class="comic-embed-wrapper">
|
||||
{{ comic.html_embed | safe }}
|
||||
</div>
|
||||
{% elif 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 %}{{ ('images/comics/' + comic.filenames[i]) | cdn_static }}{% endif %}"
|
||||
{% if not loop.first %}data-src="{{ ('images/comics/' + comic.filenames[i]) | cdn_static }}" 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="{{ ('images/comics/' + comic.mobile_filename) | cdn_static }}">
|
||||
<img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
</picture>
|
||||
{% else %}
|
||||
<img src="{{ ('images/comics/' + comic.filename) | cdn_static }}"
|
||||
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 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>
|
||||
{% endif %}
|
||||
{% 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="{{ 'images/icons/first.png' | cdn_static }}" alt="">
|
||||
</a>
|
||||
<a href="{{ url_for('comic', comic_id=comic.number - 1) }}" class="btn-icon-nav" aria-label="Previous">
|
||||
<img src="{{ 'images/icons/previous.png' | cdn_static }}" alt="">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="First" aria-disabled="true">
|
||||
<img src="{{ 'images/icons/first.png' | cdn_static }}" alt="">
|
||||
</span>
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Previous" aria-disabled="true">
|
||||
<img src="{{ 'images/icons/previous.png' | cdn_static }}" alt="">
|
||||
</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>
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" class="btn-icon-nav" aria-label="Next">
|
||||
<img src="{{ 'images/icons/next.png' | cdn_static }}" alt="">
|
||||
</a>
|
||||
<a href="{{ url_for('comic', comic_id=total_comics) }}" class="btn-icon-nav" aria-label="Latest">
|
||||
<img src="{{ 'images/icons/latest.png' | cdn_static }}" alt="">
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Next" aria-disabled="true">
|
||||
<img src="{{ 'images/icons/next.png' | cdn_static }}" alt="">
|
||||
</span>
|
||||
<span class="btn-icon-nav btn-icon-disabled" aria-label="Latest" aria-disabled="true">
|
||||
<img src="{{ 'images/icons/latest.png' | cdn_static }}" alt="">
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="btn btn-nav btn-disabled">Next</span>
|
||||
<span class="btn btn-nav btn-disabled">Latest</span>
|
||||
{# 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" aria-disabled="true">First</span>
|
||||
<span class="btn btn-nav btn-disabled" aria-disabled="true">Previous</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="comic-date-display">{{ comic.formatted_date }}</span>
|
||||
|
||||
{% 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" 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="{{ 'images/icons/link.png' | cdn_static }}" 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="{{ 'images/icons/embed.png' | cdn_static }}" 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.18"
|
||||
Reference in New Issue
Block a user