Compare commits
18 Commits
fcb38e593c
...
v2025.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 631bca7923 | |||
| 6dad2194a5 | |||
| 3153455355 | |||
| 52b80563ba | |||
| 61aa0aaba7 | |||
| bbd8e0a96d | |||
| 13176c68d2 | |||
| 91b6d4efeb | |||
| f71720c156 | |||
| 511c9bee48 | |||
| 866bfe4d6d | |||
| 742ff0e553 | |||
| 4ec1feb2a9 | |||
| 418ba6e4ba | |||
| 14415dfcd2 | |||
| 1dac042d25 | |||
| 0eccc4b12e | |||
| b23f2399c4 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,3 +3,7 @@
|
||||
|
||||
# This should be generated on deploy
|
||||
static/feed.rss
|
||||
static/sitemap.xml
|
||||
|
||||
# Comic data cache
|
||||
data/comics/.comics_cache.pkl
|
||||
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project uses date-based versioning (YYYY.MM.DD).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
### Changed
|
||||
### Deprecated
|
||||
### Removed
|
||||
### Fixed
|
||||
### Security
|
||||
|
||||
## [2025.11.15] - 2025-11-15
|
||||
|
||||
### Added
|
||||
- Initial version tracking system
|
||||
- `version.py` module for source code version reference
|
||||
- `VERSION` file at project root for easy access
|
||||
- HTML meta tag (`generator`) displaying version in page source
|
||||
- Version number injected into all template contexts
|
||||
- This CHANGELOG.md file to track version history
|
||||
- Version bump script (`scripts/bump_version.py`) to automate releases
|
||||
|
||||
[Unreleased]: https://git.puercito.net/mi/sunday/compare/v2025.11.15...HEAD
|
||||
[2025.11.15]: https://git.puercito.net/mi/sunday/releases/tag/v2025.11.15
|
||||
128
CLAUDE.md
128
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Sunday Comics is a Flask-based webcomic website with server-side rendering and client-side navigation. Comics are stored as simple Python dictionaries in `comics_data.py`, making the system easy to manage without a database.
|
||||
Sunday Comics is a Flask-based webcomic website with server-side rendering and client-side navigation. Comics are stored as individual YAML files in `data/comics/`, making them easy to manage without a database. Each comic gets its own file for clean organization and version control.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -24,7 +24,13 @@ python app.py
|
||||
```bash
|
||||
python scripts/add_comic.py
|
||||
```
|
||||
This creates a new entry in `comics_data.py` with defaults. Edit the file afterwards to customize title, alt_text, author_note, etc.
|
||||
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
|
||||
@@ -32,14 +38,85 @@ 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: comics_data.py
|
||||
Comics are stored as a Python list called `COMICS`. Each comic is a dictionary with:
|
||||
### Data Layer: YAML Files in data/comics/
|
||||
|
||||
Comics are stored as individual YAML files in the `data/comics/` directory. The `data_loader.py` module automatically loads all `.yaml` files (except `TEMPLATE.yaml` and `README.yaml`), sorts them by comic number, and builds the `COMICS` list.
|
||||
|
||||
**Caching:** The data loader uses automatic caching to speed up subsequent loads:
|
||||
- First load: Parses all YAML files, saves to `data/comics/.comics_cache.pkl`
|
||||
- Subsequent loads: Reads from cache (~100x faster)
|
||||
- Auto-invalidation: Cache rebuilds automatically when any YAML file is modified
|
||||
- Cache can be disabled via environment variable: `DISABLE_COMIC_CACHE=true`
|
||||
|
||||
Performance with caching (1000 comics):
|
||||
- Initial load: ~2-3 seconds (builds cache)
|
||||
- Subsequent loads: ~0.01 seconds (uses cache)
|
||||
- Scripts (RSS, sitemap): All share the same cache file on disk
|
||||
|
||||
**File structure:**
|
||||
- `data/comics/001.yaml` - Comic #1
|
||||
- `data/comics/002.yaml` - Comic #2
|
||||
- `data/comics/003.yaml` - Comic #3
|
||||
- `data/comics/TEMPLATE.yaml` - Template for new comics (ignored by loader)
|
||||
- `data/comics/README.md` - Documentation for comic files
|
||||
|
||||
**Adding a new comic:**
|
||||
1. Use `python scripts/add_comic.py` to auto-generate the next comic file
|
||||
2. OR manually copy `TEMPLATE.yaml` and rename it
|
||||
3. Edit the YAML file to set comic properties
|
||||
|
||||
Each comic YAML file contains:
|
||||
- `number` (required): Sequential comic number
|
||||
- `filename` (required): Image filename in `static/images/comics/`
|
||||
- `filename` (required): Image filename in `static/images/comics/` OR list of filenames for multi-image comics (webtoon style)
|
||||
- `date` (required): Publication date in YYYY-MM-DD format
|
||||
- `alt_text` (required): Accessibility text
|
||||
- `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`.
|
||||
@@ -47,6 +124,24 @@ Comics are stored as a Python list called `COMICS`. Each comic is a dictionary w
|
||||
- `plain` (optional): Override global PLAIN_DEFAULT setting (hides header/border)
|
||||
- `section` (optional): Section/chapter title (e.g., "Chapter 1: Origins"). Add to first comic of a new section.
|
||||
|
||||
**Multi-image comics (webtoon style) in YAML:**
|
||||
```yaml
|
||||
filename:
|
||||
- page1.png
|
||||
- page2.png
|
||||
- page3.png
|
||||
alt_text:
|
||||
- "Description 1"
|
||||
- "Description 2"
|
||||
- "Description 3"
|
||||
```
|
||||
- Set `filename` to a list of image filenames
|
||||
- Set `alt_text` to either a single string (applies to all images) or a list matching each image
|
||||
- If `alt_text` is a list but doesn't match `filename` length, a warning is logged
|
||||
- Images display vertically with seamless stacking (no gaps)
|
||||
- First image loads immediately; subsequent images lazy-load as user scrolls
|
||||
- No click-through navigation on multi-image comics (use navigation buttons instead)
|
||||
|
||||
Global configuration in `comics_data.py`:
|
||||
- `COMIC_NAME`: Your comic/website name
|
||||
- `COPYRIGHT_NAME`: Name to display in copyright notice (defaults to COMIC_NAME if not set)
|
||||
@@ -134,24 +229,29 @@ Global context variables injected into all templates:
|
||||
- `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 ordering**: COMICS list order determines comic sequence. Last item is the "latest" comic.
|
||||
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. **Enrichment pattern**: Always use `enrich_comic()` before passing comics to templates or APIs. This adds computed properties like `full_width`, `plain`, and `formatted_date`.
|
||||
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. **Date formatting**: The `format_comic_date()` function uses `%d` with lstrip('0') for cross-platform compatibility (not all systems support `%-d`).
|
||||
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. **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.
|
||||
4. **Date formatting**: The `format_comic_date()` function uses `%d` with lstrip('0') for cross-platform compatibility (not all systems support `%-d`).
|
||||
|
||||
5. **Settings cascade**: Global settings (FULL_WIDTH_DEFAULT, PLAIN_DEFAULT) apply unless overridden per-comic with `full_width` or `plain` keys.
|
||||
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. **Navigation state**: Client-side navigation reads `data-total-comics` and `data-comic-number` from the `.comic-container` element to manage button states.
|
||||
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. **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).
|
||||
7. **Navigation state**: Client-side navigation reads `data-total-comics` and `data-comic-number` from the `.comic-container` element to manage button states.
|
||||
|
||||
8. **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.
|
||||
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
|
||||
|
||||
|
||||
635
README.md
635
README.md
@@ -18,6 +18,19 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
|
||||
- [Testing Accessibility](#testing-accessibility)
|
||||
- [Accessibility Best Practices for Comic Creators](#accessibility-best-practices-for-comic-creators)
|
||||
- [Accessibility Score](#accessibility-score)
|
||||
- [Search Engine Optimization (SEO)](#search-engine-optimization-seo)
|
||||
- [SEO Features](#seo-features)
|
||||
- [Using SEO Features](#using-seo-features)
|
||||
- [SEO Best Practices for Webcomics](#seo-best-practices-for-webcomics)
|
||||
- [SEO Checklist for Launch](#seo-checklist-for-launch)
|
||||
- [Common SEO Questions](#common-seo-questions)
|
||||
- [Content Protection & AI Scraping Prevention](#content-protection--ai-scraping-prevention)
|
||||
- [Protection Features](#protection-features)
|
||||
- [Advanced: Image-Level Protection Tools](#advanced-image-level-protection-tools)
|
||||
- [Important Limitations](#important-limitations)
|
||||
- [Customizing Your Terms](#customizing-your-terms)
|
||||
- [Testing Your Protection](#testing-your-protection)
|
||||
- [Reporting Violations](#reporting-violations)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Setup](#setup)
|
||||
- [Environment Variables](#environment-variables)
|
||||
@@ -59,7 +72,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
|
||||
- An archive page where readers can browse all your comics
|
||||
- RSS feed so readers can subscribe to updates
|
||||
- Mobile-friendly design that works on phones and tablets
|
||||
- No database required - just upload images and edit a simple text file
|
||||
- No database required - just upload images and edit simple YAML files
|
||||
|
||||
**Perfect for:**
|
||||
- Independent comic artists starting their first webcomic
|
||||
@@ -68,7 +81,7 @@ A Flask-based webcomic website with server-side rendering using Jinja2 templates
|
||||
- Anyone looking for a lightweight, customizable comic platform
|
||||
|
||||
**How it works:**
|
||||
You add your comics by uploading image files and adding basic information (title, date, description) to a configuration file. The website handles everything else - displaying comics with navigation, creating an archive, generating an RSS feed, and making your comics shareable on social media.
|
||||
You add your comics by uploading image files and creating individual YAML files with basic information (title, date, description). The website handles everything else - displaying comics with navigation, creating an archive, generating an RSS feed, and making your comics shareable on social media.
|
||||
|
||||
No coding knowledge required for basic use - just follow the instructions below to add comics and customize your site's appearance.
|
||||
|
||||
@@ -78,7 +91,7 @@ No coding knowledge required for basic use - just follow the instructions below
|
||||
|
||||
**Sunday Comics:**
|
||||
- Server-side application (Flask/Python) that runs on a web server
|
||||
- Comics stored in a Python file - edit text to add comics
|
||||
- Comics stored as individual YAML files - easy version control and management
|
||||
- Includes an RSS feed generator and helper scripts
|
||||
- API endpoints for programmatic access
|
||||
- Markdown support for rich-formatted content
|
||||
@@ -87,7 +100,7 @@ No coding knowledge required for basic use - just follow the instructions below
|
||||
|
||||
**Rarebit:**
|
||||
- Purely static HTML/CSS/JavaScript files
|
||||
- Comics are inferred from static images upload - edit a JS to customize
|
||||
- Comics are inferred from static images upload - edit a JS file to customize
|
||||
- Can be hosted for free on GitHub Pages, Neocities, etc.
|
||||
- No server or programming language required
|
||||
- Simpler deployment - just upload files
|
||||
@@ -145,6 +158,7 @@ Don't have a server? No problem! Here are beginner-friendly options to get your
|
||||
## Features
|
||||
|
||||
- Comic viewer with navigation (First, Previous, Next, Latest)
|
||||
- **Multi-image comics** with vertical scrolling (webtoon style) and lazy loading
|
||||
- Client-side navigation using JSON API (no page reloads)
|
||||
- Keyboard navigation support (arrow keys, Home/End)
|
||||
- Archive page with thumbnail grid
|
||||
@@ -159,6 +173,7 @@ Don't have a server? No problem! Here are beginner-friendly options to get your
|
||||
- Open Graph and Twitter Card metadata for social sharing
|
||||
- Server-side rendering with Jinja2
|
||||
- **Comprehensive accessibility features** (WCAG compliant)
|
||||
- **Search engine optimized** (sitemap, robots.txt, meta tags, canonical URLs)
|
||||
|
||||
## Accessibility
|
||||
|
||||
@@ -218,13 +233,11 @@ To test keyboard navigation on your site:
|
||||
When adding comics to your site, follow these guidelines to maintain accessibility:
|
||||
|
||||
1. **Always provide alt text**
|
||||
```python
|
||||
{
|
||||
'number': 1,
|
||||
'filename': 'comic-001.png',
|
||||
'alt_text': 'A descriptive summary of what happens in the comic', # Required!
|
||||
# ...
|
||||
}
|
||||
```yaml
|
||||
number: 1
|
||||
filename: comic-001.png
|
||||
alt_text: A descriptive summary of what happens in the comic # Required!
|
||||
date: '2025-01-01'
|
||||
```
|
||||
|
||||
2. **Write meaningful alt text**
|
||||
@@ -253,21 +266,391 @@ Sunday Comics follows WCAG 2.1 Level AA guidelines and scores **9.5/10** in acce
|
||||
- ARIA attributes ✅
|
||||
- Semantic HTML ✅
|
||||
|
||||
## Search Engine Optimization (SEO)
|
||||
|
||||
Sunday Comics is built with SEO best practices to help readers discover your webcomic through search engines and social media.
|
||||
|
||||
### SEO Features
|
||||
|
||||
#### ✅ Sitemap & Crawling
|
||||
- **XML Sitemap** - Automatically generated sitemap that includes all comics, archive, and about pages
|
||||
- **Robots.txt** - Dynamically generated with correct URLs for your domain
|
||||
- **Canonical URLs** - Every page has a canonical URL to prevent duplicate content issues
|
||||
- **Semantic HTML** - Proper use of heading hierarchy (h1, h2, h3) for better indexing
|
||||
- **Language declaration** - `lang="en"` attribute helps search engines understand your content
|
||||
|
||||
#### ✅ Meta Tags & Descriptions
|
||||
- **Meta descriptions** - Customizable descriptions for each page
|
||||
- **Page titles** - SEO-friendly title format: "Comic Title - Your Comic Name"
|
||||
- **RSS feed link** - Included in HTML head for feed discovery
|
||||
- **Viewport meta tag** - Mobile-friendly configuration
|
||||
|
||||
#### ✅ Social Media Sharing
|
||||
- **Open Graph tags** - Optimized previews when sharing on Facebook, LinkedIn, Discord, etc.
|
||||
- **Twitter Cards** - Rich media cards with images when sharing on Twitter/X
|
||||
- **Custom preview images** - Set comic-specific images for social sharing
|
||||
- **Shareable URLs** - Clean, readable URLs like `/comic/1` instead of query parameters
|
||||
|
||||
#### ✅ Content Optimization
|
||||
- **Alt text requirement** - All comics must have descriptive alt text (helps SEO and accessibility)
|
||||
- **Structured data** - Semantic HTML provides context to search engines
|
||||
- **Mobile-responsive** - Mobile-friendly design is a ranking factor for Google
|
||||
- **Fast loading** - Server-side rendering provides quick initial page loads
|
||||
|
||||
### Using SEO Features
|
||||
|
||||
#### Generate Your Sitemap
|
||||
|
||||
After adding or updating comics, regenerate your sitemap:
|
||||
|
||||
```bash
|
||||
python scripts/generate_sitemap.py
|
||||
```
|
||||
|
||||
This creates `static/sitemap.xml` with:
|
||||
- Homepage (priority: 1.0, updated weekly)
|
||||
- Archive page (priority: 0.9, updated weekly)
|
||||
- About page (priority: 0.7, updated monthly)
|
||||
- Individual comic pages (priority: 0.8, never updated)
|
||||
|
||||
**Submit your sitemap to search engines:**
|
||||
- Google Search Console: `https://search.google.com/search-console`
|
||||
- Bing Webmaster Tools: `https://www.bing.com/webmasters`
|
||||
|
||||
Your sitemap URL will be: `https://yourdomain.com/sitemap.xml`
|
||||
|
||||
#### Configure robots.txt
|
||||
|
||||
The robots.txt file is automatically generated at `/robots.txt` with your correct domain from `SITE_URL` in `comics_data.py`. It includes:
|
||||
- Sitemap location
|
||||
- Allows all search engine crawlers
|
||||
- No disallowed paths (everything is indexable)
|
||||
|
||||
**Update your domain:**
|
||||
```python
|
||||
# In comics_data.py
|
||||
SITE_URL = 'https://yourcomic.com' # Update this for production!
|
||||
```
|
||||
|
||||
#### Customize Meta Descriptions
|
||||
|
||||
By default, pages use a generic description. To customize, edit the templates:
|
||||
|
||||
**templates/base.html** - Default description (line 9):
|
||||
```html
|
||||
<meta name="description" content="Your custom description here">
|
||||
```
|
||||
|
||||
**templates/comic.html** - Comic-specific descriptions:
|
||||
```html
|
||||
{% block meta_description %}{{ comic.alt_text }}{% endblock %}
|
||||
```
|
||||
|
||||
**templates/index.html** - Homepage description:
|
||||
```html
|
||||
{% block meta_description %}Read the latest comic from Your Comic Name{% endblock %}
|
||||
```
|
||||
|
||||
#### Optimize Social Sharing
|
||||
|
||||
**Set a default preview image:**
|
||||
|
||||
1. Create a 1200x630px image (recommended size for Open Graph)
|
||||
2. Save it in `static/images/` (e.g., `default-preview.png`)
|
||||
3. Update `base.html` line 17:
|
||||
```html
|
||||
<meta property="og:image" content="{{ site_url }}/static/images/your-preview.png">
|
||||
```
|
||||
|
||||
**Comic-specific sharing images:**
|
||||
|
||||
The `comic.html` template automatically uses each comic's image for social sharing. When someone shares a comic link, the actual comic image appears in the preview.
|
||||
|
||||
#### Generate RSS Feed
|
||||
|
||||
RSS feeds help readers subscribe and improve discoverability:
|
||||
|
||||
```bash
|
||||
python scripts/generate_rss.py
|
||||
```
|
||||
|
||||
This creates `static/feed.rss` which is automatically linked in the HTML head. Readers can subscribe via:
|
||||
- `https://yourdomain.com/static/feed.rss`
|
||||
|
||||
RSS feeds also signal to search engines that your site has regularly updated content.
|
||||
|
||||
### SEO Best Practices for Webcomics
|
||||
|
||||
#### 1. Write Descriptive Alt Text
|
||||
Alt text serves dual purpose - accessibility AND SEO:
|
||||
- Include character names and actions
|
||||
- Describe the scene setting
|
||||
- Mention any important dialogue or text
|
||||
- Keep it concise but informative (1-3 sentences)
|
||||
|
||||
**Good examples:**
|
||||
- ✅ "Sarah discovers her cat plotting world domination while reading a mysterious ancient book"
|
||||
- ✅ "The hero confronts the villain in a dramatic rooftop battle at sunset"
|
||||
- ❌ "Comic" (too vague)
|
||||
- ❌ "Comic about a cat" (not descriptive enough)
|
||||
|
||||
#### 2. Use Meaningful Comic Titles
|
||||
If you set a title for each comic (optional), make it:
|
||||
- Descriptive of the comic's content or theme
|
||||
- Unique (don't reuse titles)
|
||||
- Natural language (avoid "Comic #1" style)
|
||||
- Under 60 characters for best search result display
|
||||
|
||||
#### 3. Update Regularly
|
||||
- Consistent publishing schedule signals quality to search engines
|
||||
- Run `generate_sitemap.py` after each new comic
|
||||
- Update your about page periodically
|
||||
|
||||
#### 4. Build Backlinks
|
||||
- List your comic in webcomic directories
|
||||
- Use the shareable banner feature to encourage fan sites to link back
|
||||
- Engage with other webcomic communities
|
||||
- Share on social media regularly
|
||||
|
||||
#### 5. Monitor Your SEO
|
||||
Free tools to track your webcomic's search performance:
|
||||
- **Google Search Console** - See how people find your site, track indexing issues
|
||||
- **Bing Webmaster Tools** - Similar to Google, for Bing search
|
||||
- **Google Analytics** - Track visitor behavior (requires separate setup)
|
||||
|
||||
#### 6. Domain and URL Structure
|
||||
- Use a memorable domain name
|
||||
- Keep URLs clean: `yourcomic.com/comic/1` ✅ vs `yourcomic.com/?p=1` ❌
|
||||
- Sunday Comics uses SEO-friendly URLs by default
|
||||
|
||||
#### 7. Page Speed
|
||||
- Optimize comic images (use compression, appropriate file formats)
|
||||
- Consider creating thumbnails for the archive page
|
||||
- Use a CDN for static assets if you have high traffic
|
||||
- Sunday Comics uses server-side rendering for fast initial loads
|
||||
|
||||
### SEO Checklist for Launch
|
||||
|
||||
Before going live with your webcomic:
|
||||
|
||||
- [ ] Update `SITE_URL` in `comics_data.py` to your production domain
|
||||
- [ ] Generate sitemap: `python scripts/generate_sitemap.py`
|
||||
- [ ] Generate RSS feed: `python scripts/generate_rss.py`
|
||||
- [ ] Customize meta description in `templates/base.html`
|
||||
- [ ] Create a 1200x630px social preview image
|
||||
- [ ] Write descriptive alt text for all comics
|
||||
- [ ] Set up Google Search Console account
|
||||
- [ ] Submit your sitemap to Google Search Console
|
||||
- [ ] Submit your sitemap to Bing Webmaster Tools
|
||||
- [ ] Test social sharing on Facebook, Twitter, Discord
|
||||
- [ ] Update your about page with relevant keywords
|
||||
- [ ] Set up RSS feed in Feedly or similar to verify it works
|
||||
|
||||
### Common SEO Questions
|
||||
|
||||
**Q: How long until my comic appears in Google?**
|
||||
A: Typically 1-4 weeks after submitting your sitemap. You can request indexing in Google Search Console to speed this up.
|
||||
|
||||
**Q: Should I include keywords in my comic alt text?**
|
||||
A: Only naturally. Focus on accurate descriptions first. Keyword stuffing hurts SEO and accessibility.
|
||||
|
||||
**Q: Do I need to regenerate the sitemap for every comic?**
|
||||
A: Yes, run `python scripts/generate_sitemap.py` after adding new comics so search engines know about them.
|
||||
|
||||
**Q: What about social media hashtags?**
|
||||
A: Hashtags don't directly affect search engine SEO, but they help social media discoverability. Use relevant community hashtags like #webcomics #comics #indiecomics.
|
||||
|
||||
**Q: Should I create a blog for my comic?**
|
||||
A: Optional, but regular blog content about your comic's development can improve SEO through fresh content and more keywords.
|
||||
|
||||
## Content Protection & AI Scraping Prevention
|
||||
|
||||
Sunday Comics includes built-in measures to discourage AI web scrapers from using your creative work for training machine learning models without permission.
|
||||
|
||||
### Protection Features
|
||||
|
||||
#### robots.txt Blocking
|
||||
The dynamically generated `robots.txt` file blocks known AI crawlers while still allowing legitimate search engines:
|
||||
|
||||
**Blocked AI bots:**
|
||||
- **GPTBot** & **ChatGPT-User** (OpenAI)
|
||||
- **CCBot** (Common Crawl - used by many AI companies)
|
||||
- **anthropic-ai** & **Claude-Web** (Anthropic)
|
||||
- **Google-Extended** (Google's AI training crawler, separate from Googlebot)
|
||||
- **PerplexityBot** (Perplexity AI)
|
||||
- **Omgilibot**, **Diffbot**, **Bytespider**, **FacebookBot**, **ImagesiftBot**, **cohere-ai**
|
||||
|
||||
**Note:** Regular search engine crawlers (Googlebot, Bingbot, etc.) are still allowed so your comic can be discovered through search.
|
||||
|
||||
The robots.txt also includes a reference to your Terms of Service for transparency.
|
||||
|
||||
#### HTML Meta Tags
|
||||
Every page includes meta tags that signal to AI scrapers not to use the content:
|
||||
|
||||
```html
|
||||
<meta name="robots" content="noai, noimageai">
|
||||
<meta name="googlebot" content="noai, noimageai">
|
||||
```
|
||||
|
||||
- `noai` - Prevents AI training on text content
|
||||
- `noimageai` - Prevents AI training on images (your comics)
|
||||
|
||||
#### Terms of Service
|
||||
A comprehensive Terms of Service page at `/terms` legally prohibits:
|
||||
- Using content for AI training or machine learning
|
||||
- Scraping or harvesting content for datasets
|
||||
- Creating derivative works using AI trained on your content
|
||||
- Text and Data Mining (TDM) without permission
|
||||
|
||||
The Terms page is automatically linked in your footer and includes:
|
||||
- Copyright protection assertions
|
||||
- DMCA enforcement information
|
||||
- TDM rights reservation (EU Directive 2019/790 Article 4)
|
||||
- Clear permitted use guidelines
|
||||
|
||||
#### HTTP Headers
|
||||
Sunday Comics automatically adds `X-Robots-Tag: noai, noimageai` headers to all responses for additional AI blocking enforcement.
|
||||
|
||||
#### TDM Reservation File
|
||||
The `/tdmrep.json` endpoint formally reserves Text and Data Mining rights under EU Directive 2019/790, pointing to your Terms of Service.
|
||||
|
||||
### Advanced: Image-Level Protection Tools
|
||||
|
||||
For artists who want to protect their work at the image level, consider these specialized tools:
|
||||
|
||||
#### Glaze (Style Protection)
|
||||
**What it does:** Adds imperceptible changes to images that prevent AI models from accurately learning your artistic style.
|
||||
|
||||
**Best for:**
|
||||
- Protecting your unique art style from being copied by AI
|
||||
- Making AI-generated imitations look wrong or distorted
|
||||
- Artists concerned about style mimicry (e.g., "draw like [artist name]" prompts)
|
||||
|
||||
**How to use:**
|
||||
1. Download from [glaze.cs.uchicago.edu](https://glaze.cs.uchicago.edu)
|
||||
2. Process your comic images before uploading to your site
|
||||
3. The changes are invisible to humans but confuse AI models
|
||||
|
||||
**Trade-offs:**
|
||||
- Processing time: Can take several minutes per image
|
||||
- Slight file size increase
|
||||
- Requires reprocessing all comics
|
||||
|
||||
#### Nightshade (Data Poisoning)
|
||||
**What it does:** Makes images appear as something completely different to AI models while looking normal to humans.
|
||||
|
||||
**Best for:**
|
||||
- Active defense against unauthorized AI training
|
||||
- Making scraped data actively harmful to AI models
|
||||
- Artists who want to fight back against scraping
|
||||
|
||||
**How to use:**
|
||||
1. Download from [nightshade.cs.uchicago.edu](https://nightshade.cs.uchicago.edu)
|
||||
2. Process images before uploading (can combine with Glaze)
|
||||
3. AI models trained on these images will produce incorrect results
|
||||
|
||||
**Trade-offs:**
|
||||
- More aggressive than Glaze (may violate some ToS)
|
||||
- Processing time similar to Glaze
|
||||
- Ongoing research tool, effectiveness may vary
|
||||
|
||||
#### Recommendations
|
||||
- **Use Glaze if:** You want passive protection for your art style
|
||||
- **Use Nightshade if:** You want active defense and accept the risks
|
||||
- **Use both if:** Maximum protection is your priority
|
||||
- **Combine with Sunday Comics protections:** These tools complement the web-based protections (robots.txt, meta tags, etc.)
|
||||
|
||||
**Note:** Both tools are free, open-source projects from the University of Chicago's SAND Lab, specifically designed to help artists protect their work from AI exploitation.
|
||||
|
||||
### Important Limitations
|
||||
|
||||
**These measures are voluntary** - they only work if AI companies respect them:
|
||||
|
||||
✅ **What this does:**
|
||||
- Signals your intent to protect your content
|
||||
- Provides legal grounding for DMCA takedowns
|
||||
- Blocks responsible AI companies that honor robots.txt
|
||||
- Makes your copyright stance clear to users and crawlers
|
||||
|
||||
❌ **What this doesn't do:**
|
||||
- Cannot physically prevent determined bad actors from scraping
|
||||
- Cannot remove already-scraped historical data from existing datasets
|
||||
- No guarantee all AI companies will honor these signals
|
||||
|
||||
**Companies that claim to honor robots.txt:**
|
||||
- OpenAI (GPTBot blocking)
|
||||
- Anthropic (anthropic-ai blocking)
|
||||
- Google (Google-Extended blocking, separate from search)
|
||||
|
||||
### Customizing Your Terms
|
||||
|
||||
Edit `/Users/pori/PycharmProjects/sunday/content/terms.md` to customize:
|
||||
|
||||
1. **Jurisdiction** - Add your country/state for legal clarity
|
||||
2. **Permitted use** - Adjust what you allow (fan art, sharing, etc.)
|
||||
3. **Contact info** - Automatically populated from `comics_data.py`
|
||||
|
||||
The Terms page uses Jinja2 template variables that pull from your configuration:
|
||||
- `{{ copyright_name }}` - From `COPYRIGHT_NAME` in `comics_data.py`
|
||||
- `{{ social_email }}` - From `SOCIAL_EMAIL` in `comics_data.py`
|
||||
|
||||
### Testing Your Protection
|
||||
|
||||
**Verify robots.txt:**
|
||||
```bash
|
||||
curl https://yourcomic.com/robots.txt
|
||||
```
|
||||
|
||||
You should see AI bot blocks and a link to your terms.
|
||||
|
||||
**Check meta tags:**
|
||||
View page source and look for:
|
||||
```html
|
||||
<meta name="robots" content="noai, noimageai">
|
||||
```
|
||||
|
||||
**Validate Terms page:**
|
||||
Visit `https://yourcomic.com/terms` to ensure it renders correctly.
|
||||
|
||||
### Reporting Violations
|
||||
|
||||
If you discover your work in an AI training dataset or being used without permission:
|
||||
|
||||
1. **Document the violation** - Screenshots, URLs, timestamps
|
||||
2. **Review their TOS** - Many AI services have content dispute processes
|
||||
3. **Send DMCA takedown** - Your Terms of Service provides legal standing
|
||||
4. **Contact the platform** - Use your `SOCIAL_EMAIL` from the Terms page
|
||||
|
||||
Resources:
|
||||
- [US Copyright Office DMCA](https://www.copyright.gov/dmca/)
|
||||
- [EU Copyright Directive](https://digital-strategy.ec.europa.eu/en/policies/copyright-legislation)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
sunday/
|
||||
├── app.py # Main Flask application
|
||||
├── comics_data.py # Comic data and configuration
|
||||
├── comics_data.py # Global configuration (not comic data)
|
||||
├── data_loader.py # YAML comic loader with caching
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Dockerfile # Production Docker image
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── .dockerignore # Docker build exclusions
|
||||
├── data/ # Comic data directory
|
||||
│ └── comics/ # Individual comic YAML files
|
||||
│ ├── 001.yaml # Comic #1
|
||||
│ ├── 002.yaml # Comic #2
|
||||
│ ├── TEMPLATE.yaml # Template for new comics
|
||||
│ └── .comics_cache.pkl # Auto-generated cache file
|
||||
├── scripts/ # Utility scripts
|
||||
│ ├── add_comic.py # Script to add new comic entries
|
||||
│ └── generate_rss.py # Script to generate RSS feed
|
||||
│ ├── add_comic.py # Create new comic YAML files
|
||||
│ ├── generate_rss.py # Generate RSS feed
|
||||
│ ├── generate_sitemap.py # Generate sitemap.xml
|
||||
│ ├── rebuild_cache.py # Force rebuild comics cache
|
||||
│ └── publish_comic.py # Rebuild cache + RSS + sitemap
|
||||
├── content/ # Markdown content
|
||||
│ ├── about.md # About page content
|
||||
│ ├── terms.md # Terms of Service
|
||||
│ └── author_notes/ # Author notes for comics (by date)
|
||||
├── templates/ # Jinja2 templates
|
||||
│ ├── base.html # Base template with navigation
|
||||
@@ -285,7 +668,8 @@ sunday/
|
||||
│ ├── comics/ # Comic images
|
||||
│ ├── thumbs/ # Thumbnail images for archive
|
||||
│ └── icons/ # Navigation and social icons (optional)
|
||||
└── feed.rss # RSS feed (generated)
|
||||
├── feed.rss # RSS feed (generated)
|
||||
└── sitemap.xml # Sitemap (generated)
|
||||
```
|
||||
|
||||
## Setup
|
||||
@@ -309,6 +693,7 @@ The app can be configured via environment variables:
|
||||
- `SECRET_KEY` - Flask secret key (defaults to 'your-secret-key')
|
||||
- `PORT` - Port to run on (defaults to 3000)
|
||||
- `DEBUG` - Enable debug mode (defaults to False)
|
||||
- `DISABLE_COMIC_CACHE` - Set to 'true' to disable comic caching (useful for debugging)
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
@@ -323,9 +708,15 @@ export PORT=3000
|
||||
python app.py
|
||||
```
|
||||
|
||||
**Disable caching (debugging):**
|
||||
```bash
|
||||
export DISABLE_COMIC_CACHE=true
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The `comics_data.py` file contains both comic data and global configuration options:
|
||||
The `comics_data.py` file contains global configuration options for your comic site. Comic data itself is stored in individual YAML files in the `data/comics/` directory.
|
||||
|
||||
### Global Settings
|
||||
|
||||
@@ -342,10 +733,14 @@ FOOTER_IMAGE = None # Optional footer image path
|
||||
BANNER_IMAGE = 'banner.jpg' # Shareable banner for "Link to Me" section
|
||||
COMPACT_FOOTER = False # Display footer in compact mode
|
||||
ARCHIVE_FULL_WIDTH = True # Full-width archive with 4 columns
|
||||
SECTIONS_ENABLED = True # Enable section headers on archive page
|
||||
USE_COMIC_NAV_ICONS = True # Use icons for comic navigation buttons
|
||||
USE_HEADER_NAV_ICONS = True # Show icons in main header navigation
|
||||
USE_FOOTER_SOCIAL_ICONS = True # Use icons for social links
|
||||
USE_SHARE_ICONS = True # Use icons in share buttons (permalink/embed)
|
||||
NEWSLETTER_ENABLED = False # Show newsletter section in footer
|
||||
EMBED_ENABLED = True # Enable comic embed functionality
|
||||
PERMALINK_ENABLED = True # Enable permalink copy button
|
||||
SOCIAL_INSTAGRAM = None # Instagram URL (or None)
|
||||
SOCIAL_YOUTUBE = None # YouTube URL (or None)
|
||||
SOCIAL_EMAIL = None # Email mailto link (or None)
|
||||
@@ -354,67 +749,107 @@ API_SPEC_LINK = None # API documentation link (or None)
|
||||
|
||||
## Adding Comics
|
||||
|
||||
Comics are stored in the `COMICS` list in `comics_data.py`. Each comic entry:
|
||||
Comics are stored as individual YAML files in the `data/comics/` directory. Each comic file contains:
|
||||
|
||||
```python
|
||||
{
|
||||
'number': 1, # Comic number (required, sequential)
|
||||
'filename': 'comic-001.png', # Image filename (required)
|
||||
'mobile_filename': 'comic-001-mobile.png', # Optional mobile version
|
||||
'date': '2025-01-01', # Publication date (required)
|
||||
'alt_text': 'Alt text for comic', # Accessibility text (required)
|
||||
'title': 'Comic Title', # Title (optional, shows #X if absent)
|
||||
'author_note': 'Optional note', # Author note (optional, plain text)
|
||||
'author_note_md': '2025-01-01.md', # Optional markdown file for author note
|
||||
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT
|
||||
'plain': True # Optional: override PLAIN_DEFAULT
|
||||
}
|
||||
```yaml
|
||||
number: 1 # Comic number (required, sequential)
|
||||
filename: comic-001.png # Image filename (required) OR list for multi-image
|
||||
date: '2025-01-01' # Publication date (required)
|
||||
alt_text: Alt text for comic # Accessibility text (required) OR list for multi-image
|
||||
title: Comic Title # Title (optional, shows #X if absent)
|
||||
author_note: Optional note # Author note (optional, plain text)
|
||||
author_note_md: 2025-01-01.md # Optional markdown file for author note
|
||||
full_width: true # Optional: override FULL_WIDTH_DEFAULT
|
||||
plain: true # Optional: override PLAIN_DEFAULT
|
||||
```
|
||||
|
||||
**For multi-image comics (webtoon style):**
|
||||
```yaml
|
||||
number: 2
|
||||
filename:
|
||||
- page1.png
|
||||
- page2.png
|
||||
- page3.png
|
||||
alt_text:
|
||||
- Panel 1 description
|
||||
- Panel 2 description
|
||||
- Panel 3 description
|
||||
date: '2025-01-08'
|
||||
full_width: true # Recommended for webtoons
|
||||
```
|
||||
|
||||
### Adding a New Comic
|
||||
|
||||
**Option 1: Use the script (recommended)**
|
||||
```bash
|
||||
# Add comic entry only
|
||||
# Add comic YAML file with defaults
|
||||
python scripts/add_comic.py
|
||||
|
||||
# Add comic entry AND create markdown file for author notes
|
||||
# Add comic YAML file AND create markdown file for author notes
|
||||
python scripts/add_comic.py -m
|
||||
```
|
||||
This will automatically add a new entry with defaults. The `-m` flag creates a markdown file in `content/author_notes/{date}.md` with a template and adds the `author_note_md` field to the comic entry. Then edit `comics_data.py` to customize.
|
||||
This will create a new YAML file in `data/comics/` with the next comic number and reasonable defaults. The `-m` flag also creates a markdown file in `content/author_notes/{date}.md` with a template. Then:
|
||||
1. Edit the generated YAML file to customize title, alt_text, author_note, etc.
|
||||
2. Upload your comic image to `static/images/comics/`
|
||||
3. Optionally create a thumbnail in `static/images/thumbs/` with the same filename
|
||||
|
||||
**Option 2: Manual**
|
||||
1. Save your comic image in `static/images/comics/` (e.g., `comic-001.png`)
|
||||
2. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename
|
||||
3. Add the comic entry to the `COMICS` list in `comics_data.py`
|
||||
1. Copy `data/comics/TEMPLATE.yaml` and rename it (e.g., `003.yaml`)
|
||||
2. Edit the YAML file to set comic properties
|
||||
3. Save your comic image in `static/images/comics/`
|
||||
4. Optionally, create a thumbnail in `static/images/thumbs/` with the same filename
|
||||
|
||||
### Generating RSS Feed
|
||||
### Publishing Comics
|
||||
|
||||
After adding comics, regenerate the RSS feed:
|
||||
After adding or updating comics, use the publish script to update all generated files:
|
||||
```bash
|
||||
python scripts/generate_rss.py
|
||||
python scripts/publish_comic.py
|
||||
```
|
||||
This creates/updates `static/feed.rss`
|
||||
This convenience script rebuilds the cache and regenerates both RSS feed and sitemap in one command.
|
||||
|
||||
### Comic Caching System
|
||||
|
||||
Sunday Comics uses an automatic caching system to speed up comic loading:
|
||||
|
||||
**How it works:**
|
||||
- **First load**: Parses all YAML files, saves to `data/comics/.comics_cache.pkl`
|
||||
- **Subsequent loads**: Reads from cache (~100x faster)
|
||||
- **Auto-invalidation**: Cache rebuilds automatically when any YAML file is modified
|
||||
|
||||
**Performance (1000 comics):**
|
||||
- Initial load: ~2-3 seconds (builds cache)
|
||||
- Subsequent loads: ~0.01 seconds (uses cache)
|
||||
- Scripts share the same cache file on disk
|
||||
|
||||
**Manual cache management:**
|
||||
```bash
|
||||
# Force rebuild the cache (normally not needed)
|
||||
python scripts/rebuild_cache.py
|
||||
|
||||
# Disable caching (for debugging)
|
||||
export DISABLE_COMIC_CACHE=true
|
||||
python app.py
|
||||
```
|
||||
|
||||
The cache file is automatically excluded from git (listed in `.gitignore`).
|
||||
|
||||
### Markdown Support
|
||||
|
||||
**Author Notes:**
|
||||
Add the `author_note_md` field to your comic entry in `comics_data.py` to use markdown-formatted author notes. The field can be:
|
||||
- Just a filename (e.g., `"2025-01-01.md"`) - looked up in `content/author_notes/`
|
||||
- A path relative to `content/` (e.g., `"special/intro.md"`)
|
||||
Add the `author_note_md` field to your comic YAML file to use markdown-formatted author notes. The field can be:
|
||||
- Just a filename (e.g., `2025-01-01.md`) - looked up in `content/author_notes/`
|
||||
- A path relative to `content/` (e.g., `special/intro.md`)
|
||||
|
||||
Markdown author notes take precedence over the plain text `author_note` field and render as HTML.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# In comics_data.py
|
||||
{
|
||||
'number': 1,
|
||||
'filename': 'comic-001.png',
|
||||
'date': '2025-01-01',
|
||||
'alt_text': 'First comic',
|
||||
'author_note_md': '2025-01-01.md' # References content/author_notes/2025-01-01.md
|
||||
}
|
||||
```yaml
|
||||
# In data/comics/001.yaml
|
||||
number: 1
|
||||
filename: comic-001.png
|
||||
date: '2025-01-01'
|
||||
alt_text: First comic
|
||||
author_note_md: 2025-01-01.md # References content/author_notes/2025-01-01.md
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -427,6 +862,102 @@ The `/about` route renders `content/about.md` as HTML. Edit this file to customi
|
||||
|
||||
**Note:** Client-side navigation displays author notes as plain text. Markdown author notes only render properly on initial page load (server-side rendering). For full markdown rendering, users need to refresh the page or navigate directly to the comic URL.
|
||||
|
||||
### Multi-Image Comics (Webtoon Style)
|
||||
|
||||
Sunday Comics supports vertical scrolling comics with multiple images stacked seamlessly, perfect for webtoon-style storytelling.
|
||||
|
||||
**How it works:**
|
||||
- Set `filename` to a list of image filenames instead of a single string
|
||||
- Images display vertically with no gaps between them
|
||||
- First image loads immediately, subsequent images lazy-load as readers scroll
|
||||
- No click-through navigation on multi-image comics (use navigation buttons instead)
|
||||
|
||||
**Basic Example:**
|
||||
```yaml
|
||||
# In data/comics/004.yaml
|
||||
number: 4
|
||||
title: Webtoon Episode 1
|
||||
filename:
|
||||
- page1.jpg
|
||||
- page2.jpg
|
||||
- page3.jpg
|
||||
alt_text: A three-part vertical story # Single alt text for all images
|
||||
date: '2025-01-22'
|
||||
```
|
||||
|
||||
**Individual Alt Text (Recommended for Accessibility):**
|
||||
```yaml
|
||||
# In data/comics/005.yaml
|
||||
number: 5
|
||||
title: Long Scroll Episode
|
||||
filename:
|
||||
- scene1.png
|
||||
- scene2.png
|
||||
- scene3.png
|
||||
- scene4.png
|
||||
alt_text:
|
||||
- Opening scene showing the city at dawn
|
||||
- Character walking through the marketplace
|
||||
- Close-up of the mysterious artifact
|
||||
- Dramatic reveal of the antagonist
|
||||
date: '2025-01-29'
|
||||
```
|
||||
|
||||
**Important:** If you provide `alt_text` as a list, it should match the number of images in `filename`. If the counts don't match, you'll see a warning in the logs. To use the same alt text for all images, just provide a single string instead of a list.
|
||||
|
||||
**Features:**
|
||||
- ✅ **Seamless vertical layout** - Images stack with no visible gaps
|
||||
- ✅ **Lazy loading** - Only loads images as they scroll into view (performance optimization)
|
||||
- ✅ **Responsive** - Works on desktop and mobile devices
|
||||
- ✅ **Accessible** - Supports individual alt text for each image panel
|
||||
- ✅ **Backward compatible** - Single-image comics continue to work as before
|
||||
|
||||
**Best Practices:**
|
||||
1. **Image consistency** - Use the same width for all images in a multi-image comic for best results
|
||||
2. **Alt text per panel** - Provide individual alt text for each image to describe what's happening in that section
|
||||
3. **File naming** - Use descriptive, sequential names like `comic-004-panel-1.png`, `comic-004-panel-2.png`
|
||||
4. **Image optimization** - Compress images appropriately since readers will load multiple images per comic
|
||||
|
||||
**Example with all options:**
|
||||
```yaml
|
||||
# In data/comics/006.yaml
|
||||
number: 6
|
||||
title: 'Chapter 2: The Journey Begins'
|
||||
filename:
|
||||
- ch2-001.png
|
||||
- ch2-002.png
|
||||
- ch2-003.png
|
||||
- ch2-004.png
|
||||
- ch2-005.png
|
||||
alt_text:
|
||||
- 'Panel 1: Hero packs their bag at sunrise'
|
||||
- 'Panel 2: Saying goodbye to the village elder'
|
||||
- 'Panel 3: Walking along the forest path'
|
||||
- 'Panel 4: Encountering a mysterious stranger'
|
||||
- 'Panel 5: Accepting a map to the ancient ruins'
|
||||
date: '2025-02-05'
|
||||
author_note: This was so much fun to draw! The journey arc begins.
|
||||
full_width: true # Recommended for webtoon-style comics
|
||||
section: Chapter 2 # Optional: mark the start of a new chapter
|
||||
```
|
||||
|
||||
**Technical Details:**
|
||||
- Images appear in the order listed in the `filename` array
|
||||
- If `alt_text` is a single string, it applies to all images
|
||||
- If `alt_text` is a list, it must match the length of `filename` (or it will pad with empty strings)
|
||||
- The first image in the array is used as the thumbnail in the archive page
|
||||
- Lazy loading uses Intersection Observer API with 50px margin for smooth loading
|
||||
|
||||
**When to use multi-image:**
|
||||
- Long-form vertical scrolling stories (webtoons, manhwa style)
|
||||
- Comics with natural panel breaks across multiple images
|
||||
- Stories that benefit from vertical pacing and reveals
|
||||
|
||||
**When to stick with single images:**
|
||||
- Traditional comic strip or page layouts
|
||||
- Self-contained single-panel comics
|
||||
- When you want click-through navigation on the comic image
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production, you should **NOT** use Flask's built-in development server. Choose one of the following deployment methods:
|
||||
|
||||
254
app.py
254
app.py
@@ -3,15 +3,21 @@
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template, abort, jsonify, request
|
||||
from comics_data import (
|
||||
COMICS, COMIC_NAME, COPYRIGHT_NAME, SITE_URL, FULL_WIDTH_DEFAULT, PLAIN_DEFAULT, LOGO_IMAGE, LOGO_MODE,
|
||||
HEADER_IMAGE, FOOTER_IMAGE, BANNER_IMAGE, COMPACT_FOOTER, ARCHIVE_FULL_WIDTH, SECTIONS_ENABLED,
|
||||
USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS, USE_FOOTER_SOCIAL_ICONS, NEWSLETTER_ENABLED,
|
||||
SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK
|
||||
USE_COMIC_NAV_ICONS, USE_HEADER_NAV_ICONS, USE_FOOTER_SOCIAL_ICONS, USE_SHARE_ICONS, NEWSLETTER_ENABLED,
|
||||
SOCIAL_INSTAGRAM, SOCIAL_YOUTUBE, SOCIAL_EMAIL, API_SPEC_LINK, EMBED_ENABLED, PERMALINK_ENABLED
|
||||
)
|
||||
import markdown
|
||||
from version import __version__
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -19,6 +25,13 @@ app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key')
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_ai_blocking_headers(response):
|
||||
"""Add headers to discourage AI scraping"""
|
||||
response.headers['X-Robots-Tag'] = 'noai, noimageai'
|
||||
return response
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_global_settings():
|
||||
"""Make global settings available to all templates"""
|
||||
@@ -38,11 +51,15 @@ def inject_global_settings():
|
||||
'use_comic_nav_icons': USE_COMIC_NAV_ICONS,
|
||||
'use_header_nav_icons': USE_HEADER_NAV_ICONS,
|
||||
'use_footer_social_icons': USE_FOOTER_SOCIAL_ICONS,
|
||||
'use_share_icons': USE_SHARE_ICONS,
|
||||
'newsletter_enabled': NEWSLETTER_ENABLED,
|
||||
'social_instagram': SOCIAL_INSTAGRAM,
|
||||
'social_youtube': SOCIAL_YOUTUBE,
|
||||
'social_email': SOCIAL_EMAIL,
|
||||
'api_spec_link': API_SPEC_LINK
|
||||
'api_spec_link': API_SPEC_LINK,
|
||||
'embed_enabled': EMBED_ENABLED,
|
||||
'permalink_enabled': PERMALINK_ENABLED,
|
||||
'version': __version__
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +124,47 @@ def enrich_comic(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'])
|
||||
@@ -161,6 +219,19 @@ def comic(comic_id):
|
||||
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:
|
||||
@@ -193,14 +264,22 @@ def group_comics_by_section(comics_list):
|
||||
@app.route('/archive')
|
||||
def archive():
|
||||
"""Archive page showing all comics"""
|
||||
# Initial batch size for server-side rendering
|
||||
initial_batch = 24
|
||||
|
||||
# Reverse order to show newest first
|
||||
comics = [enrich_comic(comic) for comic in reversed(COMICS)]
|
||||
all_comics = [enrich_comic(comic) for comic in reversed(COMICS)]
|
||||
|
||||
# Only take the first batch for initial render
|
||||
initial_comics = all_comics[:initial_batch]
|
||||
|
||||
# Group by section if enabled
|
||||
sections = group_comics_by_section(comics)
|
||||
sections = group_comics_by_section(initial_comics)
|
||||
|
||||
return render_template('archive.html', title='Archive',
|
||||
sections=sections)
|
||||
sections=sections,
|
||||
total_comics=len(COMICS),
|
||||
initial_batch=initial_batch)
|
||||
|
||||
|
||||
@app.route('/about')
|
||||
@@ -217,11 +296,94 @@ def about():
|
||||
return render_template('page.html', title='About', content=html_content)
|
||||
|
||||
|
||||
@app.route('/terms')
|
||||
def terms():
|
||||
"""Terms of Service page"""
|
||||
from jinja2 import Template
|
||||
# Read and render the markdown file with template variables
|
||||
terms_path = os.path.join(os.path.dirname(__file__), 'content', 'terms.md')
|
||||
try:
|
||||
with open(terms_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
# First render as Jinja template to substitute variables
|
||||
template = Template(content)
|
||||
rendered_content = template.render(
|
||||
copyright_name=COPYRIGHT_NAME,
|
||||
social_email=SOCIAL_EMAIL if SOCIAL_EMAIL else '[Contact Email]'
|
||||
)
|
||||
# Then convert markdown to HTML
|
||||
html_content = markdown.markdown(rendered_content)
|
||||
except FileNotFoundError:
|
||||
html_content = '<p>Terms of Service content not found.</p>'
|
||||
return render_template('page.html', title='Terms of Service', content=html_content)
|
||||
|
||||
|
||||
@app.route('/api/comics')
|
||||
def api_comics():
|
||||
"""API endpoint - returns all comics as JSON"""
|
||||
"""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>')
|
||||
def api_comic(comic_id):
|
||||
@@ -232,6 +394,84 @@ def api_comic(comic_id):
|
||||
return jsonify(comic)
|
||||
|
||||
|
||||
@app.route('/sitemap.xml')
|
||||
def sitemap():
|
||||
"""Serve the static sitemap.xml file"""
|
||||
from flask import send_from_directory
|
||||
return send_from_directory('static', 'sitemap.xml', mimetype='application/xml')
|
||||
|
||||
|
||||
@app.route('/robots.txt')
|
||||
def robots():
|
||||
"""Generate robots.txt dynamically with correct SITE_URL"""
|
||||
from flask import Response
|
||||
robots_txt = f"""# Sunday Comics - Robots.txt
|
||||
# Content protected by copyright. AI training prohibited.
|
||||
# See terms: {SITE_URL}/terms
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Sitemap location
|
||||
Sitemap: {SITE_URL}/sitemap.xml
|
||||
|
||||
# Disallow API endpoints from indexing
|
||||
Disallow: /api/
|
||||
|
||||
# Block AI crawlers and scrapers
|
||||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
|
||||
User-agent: CCBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: Claude-Web
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Omgilibot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Diffbot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Bytespider
|
||||
Disallow: /
|
||||
|
||||
User-agent: FacebookBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ImagesiftBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: cohere-ai
|
||||
Disallow: /
|
||||
"""
|
||||
return Response(robots_txt, mimetype='text/plain')
|
||||
|
||||
|
||||
@app.route('/tdmrep.json')
|
||||
def tdm_reservation():
|
||||
"""TDM (Text and Data Mining) reservation - signals AI training prohibition"""
|
||||
return jsonify({
|
||||
"tdm": {
|
||||
"reservation": 1,
|
||||
"policy": f"{SITE_URL}/terms"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
"""404 error handler"""
|
||||
|
||||
@@ -73,6 +73,10 @@ USE_HEADER_NAV_ICONS = True
|
||||
# Uses instagram.png, youtube.png, and mail.png from static/images/icons/
|
||||
USE_FOOTER_SOCIAL_ICONS = True
|
||||
|
||||
# Global setting: Set to True to show icons in share buttons (permalink and embed)
|
||||
# Uses link.png for permalink and embed.png for embed from static/images/icons/
|
||||
USE_SHARE_ICONS = True
|
||||
|
||||
# Global setting: Set to True to show newsletter section in footer
|
||||
NEWSLETTER_ENABLED = False
|
||||
|
||||
@@ -85,34 +89,25 @@ SOCIAL_EMAIL = None # e.g., 'mailto:your@email.com'
|
||||
# Path is relative to static/ directory
|
||||
API_SPEC_LINK = None # Set to 'openapi.yml' to enable
|
||||
|
||||
COMICS = [
|
||||
{
|
||||
'number': 1,
|
||||
'title': 'First Comic',
|
||||
'filename': 'comic-001.jpg',
|
||||
'mobile_filename': 'comic-001-mobile.jpg', # Optional: mobile version of the comic
|
||||
'date': '2025-01-01',
|
||||
'alt_text': 'The very first comic',
|
||||
'author_note': 'This is where your comic journey begins!',
|
||||
'author_note_md': '2025-01-01.md', # Optional: use markdown from content/author_notes/2025-01-01.md (overrides author_note)
|
||||
'full_width': True, # Optional: override FULL_WIDTH_DEFAULT for this comic
|
||||
'plain': True, # Optional: override PLAIN_DEFAULT for this comic
|
||||
'section': 'Chapter 1: The Beginning', # Optional: start a new section on archive page
|
||||
},
|
||||
{
|
||||
'number': 2,
|
||||
'filename': 'comic-002.jpg',
|
||||
'date': '2025-01-08',
|
||||
'alt_text': 'The second comic',
|
||||
'full_width': True,
|
||||
'plain': True,
|
||||
},
|
||||
{
|
||||
'number': 3,
|
||||
'title': 'Third Comic',
|
||||
'filename': 'comic-003.jpg',
|
||||
'date': '2025-01-15',
|
||||
'alt_text': 'The third comic',
|
||||
'author_note': 'Things are getting interesting!',
|
||||
},
|
||||
]
|
||||
# Global setting: Set to True to enable comic embed functionality
|
||||
# When enabled, users can get embed codes to display comics on other websites
|
||||
EMBED_ENABLED = True
|
||||
|
||||
# Global setting: Set to True to enable permalink copy button
|
||||
# When enabled, users can easily copy a direct link to the current comic
|
||||
PERMALINK_ENABLED = True
|
||||
|
||||
# Load comics from YAML files
|
||||
from data_loader import load_comics_from_yaml, validate_comics
|
||||
|
||||
COMICS = load_comics_from_yaml('data/comics')
|
||||
|
||||
# Validate loaded comics
|
||||
if not validate_comics(COMICS):
|
||||
print("Warning: Comic validation failed. Please check your YAML files.")
|
||||
|
||||
# Show loaded comics count
|
||||
if COMICS:
|
||||
print(f"Loaded {len(COMICS)} comics from data/comics/")
|
||||
else:
|
||||
print("Warning: No comics loaded! Please add .yaml files to data/comics/")
|
||||
|
||||
93
content/terms.md
Normal file
93
content/terms.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Terms of Service
|
||||
|
||||
**Last Updated:** January 2025
|
||||
|
||||
By accessing and using this website, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use this site.
|
||||
|
||||
## Copyright and Ownership
|
||||
|
||||
All comics, artwork, text, graphics, and other content on this website are protected by copyright and owned by {{ copyright_name }}. All rights reserved.
|
||||
|
||||
## Permitted Use
|
||||
|
||||
**Personal Use:** You may:
|
||||
- Read and enjoy the comics for personal, non-commercial purposes
|
||||
- Share links to individual comic pages on social media
|
||||
- Embed comics on personal websites with proper attribution and a link back to the original
|
||||
|
||||
**Attribution Required:** When sharing or embedding, you must:
|
||||
- Provide clear credit to {{ copyright_name }}
|
||||
- Include a link back to this website
|
||||
- Not alter, crop, or modify the comic images
|
||||
|
||||
## Prohibited Use
|
||||
|
||||
You are **expressly prohibited** from:
|
||||
|
||||
### AI Training and Machine Learning
|
||||
- Using any content from this site for training artificial intelligence models
|
||||
- Scraping, crawling, or harvesting content for machine learning purposes
|
||||
- Including any images, text, or data in AI training datasets
|
||||
- Using content to develop, train, or improve generative AI systems
|
||||
- Creating derivative works using AI trained on this content
|
||||
|
||||
### Commercial Use
|
||||
- Reproducing, distributing, or selling comics without explicit written permission
|
||||
- Using comics or artwork for commercial purposes without a license
|
||||
- Printing comics on merchandise (t-shirts, mugs, etc.) without authorization
|
||||
|
||||
### Modification and Redistribution
|
||||
- Altering, editing, or creating derivative works from the comics
|
||||
- Removing watermarks, signatures, or attribution
|
||||
- Rehosting images on other servers or websites
|
||||
- Claiming comics as your own work
|
||||
|
||||
## Data Mining and Web Scraping
|
||||
|
||||
**Automated Access Prohibition:** Automated scraping, crawling, or systematic downloading of content is strictly prohibited without prior written consent. This includes but is not limited to:
|
||||
- Web scrapers and bots (except authorized search engines)
|
||||
- Automated downloads of images or data
|
||||
- RSS feed abuse or bulk downloading
|
||||
- Any form of data harvesting for commercial purposes
|
||||
|
||||
**Text and Data Mining (TDM) Reservation:** We formally reserve all rights under applicable copyright law regarding text and data mining, including but not limited to EU Directive 2019/790 Article 4. No TDM exceptions apply to this content.
|
||||
|
||||
## DMCA and Copyright Enforcement
|
||||
|
||||
Unauthorized use of copyrighted material from this site may violate copyright law and be subject to legal action under the Digital Millennium Copyright Act (DMCA) and other applicable laws.
|
||||
|
||||
If you discover unauthorized use of content from this site, please report it to {{ social_email }}.
|
||||
|
||||
## Fair Use
|
||||
|
||||
Limited use for purposes of commentary, criticism, news reporting, teaching, or research may qualify as fair use. If you believe your use qualifies as fair use, please contact us first.
|
||||
|
||||
## License Requests
|
||||
|
||||
If you wish to use content in ways not permitted by these terms, please contact us to discuss licensing arrangements.
|
||||
|
||||
## Privacy
|
||||
|
||||
We respect your privacy. This site may use cookies for basic functionality and analytics. We do not sell personal information to third parties.
|
||||
|
||||
## External Links
|
||||
|
||||
This site may contain links to external websites. We are not responsible for the content or practices of third-party sites.
|
||||
|
||||
## Modifications to Terms
|
||||
|
||||
We reserve the right to modify these Terms of Service at any time. Changes will be posted on this page with an updated "Last Updated" date.
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about these terms, licensing requests, or to report copyright violations:
|
||||
|
||||
{{ social_email }}
|
||||
|
||||
## Governing Law
|
||||
|
||||
These Terms of Service are governed by applicable copyright law and the laws of [Your Jurisdiction].
|
||||
|
||||
---
|
||||
|
||||
**Summary:** You can read and share links to comics, but you cannot use them for AI training, scrape the site, use them commercially, or create modified versions without permission.
|
||||
23
data/comics/001.yaml
Normal file
23
data/comics/001.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Comic #1
|
||||
number: 1
|
||||
title: "First Comic"
|
||||
filename: comic-001.jpg
|
||||
mobile_filename: comic-001-mobile.jpg # Optional: mobile version of the comic
|
||||
date: "2025-01-01"
|
||||
alt_text: "The very first comic"
|
||||
|
||||
# Author notes (choose one method):
|
||||
# Option 1: Plain text note
|
||||
author_note: "This is where your comic journey begins!"
|
||||
|
||||
# Option 2: Markdown file (overrides author_note if present)
|
||||
# Just a filename looks in content/author_notes/
|
||||
# Or use a path like "special/note.md" relative to content/
|
||||
author_note_md: "2025-01-01.md"
|
||||
|
||||
# Display settings (override global defaults)
|
||||
full_width: true
|
||||
plain: true
|
||||
|
||||
# Section header (optional - only add to first comic of a new section)
|
||||
section: "Chapter 1: The Beginning"
|
||||
9
data/comics/002.yaml
Normal file
9
data/comics/002.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
# Comic #2
|
||||
number: 2
|
||||
filename: comic-002.jpg
|
||||
date: "2025-01-08"
|
||||
alt_text: "The second comic"
|
||||
|
||||
# Display settings
|
||||
full_width: true
|
||||
plain: true
|
||||
7
data/comics/003.yaml
Normal file
7
data/comics/003.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# Comic #3
|
||||
number: 3
|
||||
title: "Third Comic"
|
||||
filename: comic-003.jpg
|
||||
date: "2025-01-15"
|
||||
alt_text: "The third comic"
|
||||
author_note: "Things are getting interesting!"
|
||||
89
data/comics/README.md
Normal file
89
data/comics/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Comic Data Directory
|
||||
|
||||
This directory contains YAML files for managing individual comics. Each comic gets its own `.yaml` file.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Adding a New Comic
|
||||
|
||||
1. **Copy the template:**
|
||||
```bash
|
||||
cp TEMPLATE.yaml 004.yaml
|
||||
```
|
||||
|
||||
2. **Edit the file** with your comic's information:
|
||||
- Update `number`, `filename`, `date`, and `alt_text` (required)
|
||||
- Add optional fields like `title`, `author_note`, etc.
|
||||
|
||||
3. **Save the file** and restart your application
|
||||
|
||||
The comics will be automatically loaded and sorted by comic number.
|
||||
|
||||
## File Naming
|
||||
|
||||
You can name files anything you want (e.g., `001.yaml`, `first-comic.yaml`, `2025-01-01.yaml`), but using the comic number is recommended for easy organization.
|
||||
|
||||
## Required Fields
|
||||
|
||||
Every comic MUST have:
|
||||
- `number` - Sequential comic number (integer)
|
||||
- `filename` - Image filename (string) or list of filenames for multi-image comics
|
||||
- `date` - Publication date in YYYY-MM-DD format (string)
|
||||
- `alt_text` - Accessibility description (string or list for multi-image)
|
||||
|
||||
## Optional Fields
|
||||
|
||||
- `title` - Comic title (defaults to "#X" if not provided)
|
||||
- `mobile_filename` - Mobile-optimized version
|
||||
- `author_note` - Plain text note below the comic
|
||||
- `author_note_md` - Markdown file for author note (overrides `author_note`)
|
||||
- `full_width` - Override global width setting (boolean)
|
||||
- `plain` - Override global plain mode (boolean)
|
||||
- `section` - Start a new section/chapter (string, add only to first comic of section)
|
||||
|
||||
## Multi-Image Comics (Webtoon Style)
|
||||
|
||||
For vertical scrolling comics with multiple images:
|
||||
|
||||
```yaml
|
||||
number: 42
|
||||
filename:
|
||||
- page1.png
|
||||
- page2.png
|
||||
- page3.png
|
||||
alt_text:
|
||||
- "First panel description"
|
||||
- "Second panel description"
|
||||
- "Third panel description"
|
||||
date: "2025-01-01"
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```yaml
|
||||
number: 4
|
||||
title: "The Adventure Begins"
|
||||
filename: comic-004.jpg
|
||||
date: "2025-01-22"
|
||||
alt_text: "A hero stands at the edge of a cliff, looking at the horizon"
|
||||
author_note: "This is where things get interesting!"
|
||||
full_width: true
|
||||
section: "Chapter 2: The Journey"
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
The data loader will:
|
||||
- Skip files with missing required fields (with warnings)
|
||||
- Check for duplicate comic numbers
|
||||
- Warn about gaps in numbering
|
||||
- Sort comics by number automatically
|
||||
|
||||
## Testing Your Changes
|
||||
|
||||
Test the loader directly:
|
||||
```bash
|
||||
python data_loader.py
|
||||
```
|
||||
|
||||
This will show you all loaded comics and any validation warnings.
|
||||
51
data/comics/TEMPLATE.yaml
Normal file
51
data/comics/TEMPLATE.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
# Template for creating new comics
|
||||
# Copy this file and rename it to match your comic number (e.g., 004.yaml, 005.yaml)
|
||||
# Fields marked as REQUIRED must be included
|
||||
# All other fields are optional
|
||||
|
||||
# REQUIRED: Sequential comic number
|
||||
number: 999
|
||||
|
||||
# REQUIRED: Image filename(s) in static/images/comics/
|
||||
# Single image:
|
||||
filename: comic-999.jpg
|
||||
# OR multi-image (webtoon style):
|
||||
# filename:
|
||||
# - page1.png
|
||||
# - page2.png
|
||||
# - page3.png
|
||||
|
||||
# Optional: Mobile-optimized version of the comic
|
||||
# mobile_filename: comic-999-mobile.jpg
|
||||
|
||||
# REQUIRED: Publication date (YYYY-MM-DD format)
|
||||
date: "2025-01-01"
|
||||
|
||||
# REQUIRED: Accessibility text for screen readers
|
||||
# Single alt text (for single or multi-image):
|
||||
alt_text: "Description of what happens in this comic"
|
||||
# OR individual alt texts for multi-image comics:
|
||||
# alt_text:
|
||||
# - "Description of first image"
|
||||
# - "Description of second image"
|
||||
# - "Description of third image"
|
||||
|
||||
# Optional: Comic title (defaults to "#X" if not provided)
|
||||
title: "Title of Your Comic"
|
||||
|
||||
# Optional: Plain text author note
|
||||
author_note: "Your thoughts about this comic."
|
||||
|
||||
# Optional: Markdown author note file (overrides author_note if present)
|
||||
# Just filename looks in content/author_notes/
|
||||
# Or use path like "special/note.md" relative to content/
|
||||
# author_note_md: "2025-01-01.md"
|
||||
|
||||
# Optional: Override global FULL_WIDTH_DEFAULT setting
|
||||
# full_width: true
|
||||
|
||||
# Optional: Override global PLAIN_DEFAULT setting (hides header/border)
|
||||
# plain: true
|
||||
|
||||
# Optional: Section/chapter title (only add to first comic of a new section)
|
||||
# section: "Chapter 2: New Adventures"
|
||||
179
data_loader.py
Normal file
179
data_loader.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Comic data loader for YAML-based comic management with caching.
|
||||
|
||||
This module scans the data/comics/ directory for .yaml files,
|
||||
loads each comic's configuration, and builds the COMICS list.
|
||||
Caching is used to speed up subsequent loads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_comics_from_yaml(comics_dir='data/comics', use_cache=True):
|
||||
"""
|
||||
Load all comic data from YAML files with optional caching.
|
||||
|
||||
Args:
|
||||
comics_dir: Path to directory containing comic YAML files
|
||||
use_cache: Whether to use cache (set to False to force reload)
|
||||
|
||||
Returns:
|
||||
List of comic dictionaries, sorted by comic number
|
||||
"""
|
||||
comics_path = Path(comics_dir)
|
||||
|
||||
if not comics_path.exists():
|
||||
print(f"Warning: Comics directory '{comics_dir}' does not exist. Creating it...")
|
||||
comics_path.mkdir(parents=True, exist_ok=True)
|
||||
return []
|
||||
|
||||
# Cache file location
|
||||
cache_file = comics_path / '.comics_cache.pkl'
|
||||
|
||||
# Check if caching is disabled via environment variable
|
||||
if os.getenv('DISABLE_COMIC_CACHE') == 'true':
|
||||
use_cache = False
|
||||
|
||||
# Find all .yaml and .yml files
|
||||
yaml_files = list(comics_path.glob('*.yaml')) + list(comics_path.glob('*.yml'))
|
||||
|
||||
# Filter out template and README files
|
||||
yaml_files = [f for f in yaml_files if f.stem.upper() not in ('TEMPLATE', 'README')]
|
||||
|
||||
if not yaml_files:
|
||||
print(f"Warning: No YAML files found in '{comics_dir}'")
|
||||
return []
|
||||
|
||||
# Check if we can use cache
|
||||
if use_cache and cache_file.exists():
|
||||
cache_mtime = cache_file.stat().st_mtime
|
||||
|
||||
# Get the newest YAML file modification time
|
||||
newest_yaml_mtime = max(f.stat().st_mtime for f in yaml_files)
|
||||
|
||||
# If cache is newer than all YAML files, use it
|
||||
if cache_mtime >= newest_yaml_mtime:
|
||||
try:
|
||||
with open(cache_file, 'rb') as f:
|
||||
comics = pickle.load(f)
|
||||
print(f"Loaded {len(comics)} comics from cache")
|
||||
return comics
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load cache: {e}")
|
||||
# Fall through to reload from YAML
|
||||
|
||||
# Load from YAML files (cache miss or disabled)
|
||||
print(f"Loading {len(yaml_files)} comic files from YAML...")
|
||||
comics = []
|
||||
|
||||
for yaml_file in yaml_files:
|
||||
try:
|
||||
with open(yaml_file, 'r', encoding='utf-8') as f:
|
||||
comic_data = yaml.safe_load(f)
|
||||
|
||||
if comic_data is None:
|
||||
print(f"Warning: '{yaml_file.name}' is empty, skipping")
|
||||
continue
|
||||
|
||||
if 'number' not in comic_data:
|
||||
print(f"Warning: '{yaml_file.name}' missing required 'number' field, skipping")
|
||||
continue
|
||||
|
||||
if 'filename' not in comic_data:
|
||||
print(f"Warning: '{yaml_file.name}' missing required 'filename' field, skipping")
|
||||
continue
|
||||
|
||||
if 'date' not in comic_data:
|
||||
print(f"Warning: '{yaml_file.name}' missing required 'date' field, skipping")
|
||||
continue
|
||||
|
||||
if 'alt_text' not in comic_data:
|
||||
print(f"Warning: '{yaml_file.name}' missing required 'alt_text' field, skipping")
|
||||
continue
|
||||
|
||||
comics.append(comic_data)
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Error parsing '{yaml_file.name}': {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error loading '{yaml_file.name}': {e}")
|
||||
continue
|
||||
|
||||
# Sort by comic number
|
||||
comics.sort(key=lambda c: c['number'])
|
||||
|
||||
# Save to cache
|
||||
if use_cache:
|
||||
try:
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(comics, f)
|
||||
print(f"Saved {len(comics)} comics to cache")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to save cache: {e}")
|
||||
|
||||
return comics
|
||||
|
||||
|
||||
def clear_cache(comics_dir='data/comics'):
|
||||
"""
|
||||
Clear the comics cache file.
|
||||
|
||||
Args:
|
||||
comics_dir: Path to directory containing comic YAML files
|
||||
"""
|
||||
cache_file = Path(comics_dir) / '.comics_cache.pkl'
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
print("Cache cleared")
|
||||
return True
|
||||
else:
|
||||
print("No cache file found")
|
||||
return False
|
||||
|
||||
|
||||
def validate_comics(comics):
|
||||
"""
|
||||
Validate the loaded comics for common issues.
|
||||
|
||||
Args:
|
||||
comics: List of comic dictionaries
|
||||
|
||||
Returns:
|
||||
True if validation passes, False otherwise
|
||||
"""
|
||||
if not comics:
|
||||
return True
|
||||
|
||||
numbers = [c['number'] for c in comics]
|
||||
|
||||
# Check for duplicate comic numbers
|
||||
if len(numbers) != len(set(numbers)):
|
||||
duplicates = [n for n in numbers if numbers.count(n) > 1]
|
||||
print(f"Warning: Duplicate comic numbers found: {set(duplicates)}")
|
||||
return False
|
||||
|
||||
# Check for gaps in comic numbering (optional warning)
|
||||
for i in range(len(comics) - 1):
|
||||
if comics[i+1]['number'] - comics[i]['number'] > 1:
|
||||
print(f"Info: Gap in comic numbering between {comics[i]['number']} and {comics[i+1]['number']}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Test the loader
|
||||
print("Loading comics from data/comics/...")
|
||||
comics = load_comics_from_yaml()
|
||||
print(f"Loaded {len(comics)} comics")
|
||||
|
||||
if validate_comics(comics):
|
||||
print("Validation passed!")
|
||||
for comic in comics:
|
||||
title = comic.get('title', f"#{comic['number']}")
|
||||
print(f" - Comic {comic['number']}: {title} ({comic['date']})")
|
||||
else:
|
||||
print("Validation failed!")
|
||||
@@ -1,2 +1,3 @@
|
||||
Flask==3.0.0
|
||||
markdown==3.5.1
|
||||
PyYAML==6.0.3
|
||||
@@ -4,7 +4,7 @@
|
||||
# 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
|
||||
@@ -51,7 +51,7 @@ Write your author note here using markdown formatting.
|
||||
|
||||
def main():
|
||||
"""Add a new comic entry with defaults"""
|
||||
parser = argparse.ArgumentParser(description='Add a new comic entry to comics_data.py')
|
||||
parser = argparse.ArgumentParser(description='Add a new comic entry as a YAML file')
|
||||
parser.add_argument('-m', '--markdown', action='store_true',
|
||||
help='Generate a markdown file for author notes and add author_note_md field to comic entry')
|
||||
args = parser.parse_args()
|
||||
@@ -59,53 +59,75 @@ def main():
|
||||
# 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)
|
||||
|
||||
# Check if file already exists
|
||||
if os.path.exists(yaml_file):
|
||||
print(f"Error: Comic file already exists: {yaml_file}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create YAML file with comments
|
||||
yaml_content = f"""# Comic #{number}
|
||||
number: {number}
|
||||
filename: {comic_data['filename']}
|
||||
date: "{date_str}"
|
||||
alt_text: "{comic_data['alt_text']}"
|
||||
"""
|
||||
|
||||
# Format new entry
|
||||
if args.markdown:
|
||||
entry_str = f""" {{
|
||||
'number': {comic['number']},
|
||||
'filename': {repr(comic['filename'])},
|
||||
'date': {repr(comic['date'])},
|
||||
'alt_text': {repr(comic['alt_text'])},
|
||||
'author_note_md': {repr(comic['date'] + '.md')}
|
||||
}}"""
|
||||
yaml_content += f'\n# Markdown author note (overrides author_note if present)\nauthor_note_md: "{date_str}.md"\n'
|
||||
else:
|
||||
entry_str = f""" {{
|
||||
'number': {comic['number']},
|
||||
'filename': {repr(comic['filename'])},
|
||||
'date': {repr(comic['date'])},
|
||||
'alt_text': {repr(comic['alt_text'])}
|
||||
}}"""
|
||||
yaml_content += '\n# Optional: Add author note\n# author_note: "Your thoughts about this comic."\n'
|
||||
|
||||
# Insert before closing bracket
|
||||
insert_pos = content.rfind(']')
|
||||
new_content = content[:insert_pos] + entry_str + ",\n" + content[insert_pos:]
|
||||
yaml_content += """
|
||||
# Optional: Add a title
|
||||
# title: "Title of Your Comic"
|
||||
|
||||
# Write back
|
||||
with open(comics_file, 'w') as f:
|
||||
f.write(new_content)
|
||||
# Optional: Override global settings
|
||||
# full_width: true
|
||||
# plain: true
|
||||
|
||||
print(f"Added comic #{number}")
|
||||
# Optional: Start a new section (only add to first comic of section)
|
||||
# section: "Chapter X: Title"
|
||||
"""
|
||||
|
||||
# Write YAML file
|
||||
with open(yaml_file, 'w') as f:
|
||||
f.write(yaml_content)
|
||||
|
||||
print(f"Created comic #{number}: {yaml_file}")
|
||||
|
||||
# Create markdown file if requested
|
||||
if args.markdown:
|
||||
create_markdown_file(comic['date'], parent_dir)
|
||||
create_markdown_file(date_str, parent_dir)
|
||||
|
||||
print(f"\nNext steps:")
|
||||
print(f"1. Add your comic image as: static/images/comics/{comic_data['filename']}")
|
||||
print(f"2. Edit {yaml_file} to customize the comic metadata")
|
||||
if args.markdown:
|
||||
print(f"3. Edit content/author_notes/{date_str}.md to write your author note")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
154
scripts/bump_version.py
Executable file
154
scripts/bump_version.py
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sunday Comics - Version bump script
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
"""
|
||||
Script to bump the project version number
|
||||
|
||||
Usage:
|
||||
python scripts/bump_version.py # Use today's date
|
||||
python scripts/bump_version.py 2025.12.25 # Use specific date
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
|
||||
|
||||
def validate_version(version_str):
|
||||
"""Validate version format (YYYY.MM.DD)"""
|
||||
pattern = r'^\d{4}\.\d{2}\.\d{2}$'
|
||||
if not re.match(pattern, version_str):
|
||||
return False
|
||||
|
||||
# Try to parse as a date to ensure it's valid
|
||||
try:
|
||||
parts = version_str.split('.')
|
||||
year, month, day = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
datetime(year, month, day)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def get_current_version(parent_dir):
|
||||
"""Read current version from version.py"""
|
||||
version_file = os.path.join(parent_dir, 'version.py')
|
||||
try:
|
||||
with open(version_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def update_version_py(parent_dir, new_version):
|
||||
"""Update version.py with new version"""
|
||||
version_file = os.path.join(parent_dir, 'version.py')
|
||||
|
||||
content = f"""# Sunday Comics Version
|
||||
# This file contains the version number for the project
|
||||
# Format: YYYY.MM.DD (date-based versioning)
|
||||
|
||||
__version__ = "{new_version}"
|
||||
"""
|
||||
|
||||
with open(version_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"✓ Updated {version_file}")
|
||||
|
||||
|
||||
def update_version_file(parent_dir, new_version):
|
||||
"""Update VERSION file with new version"""
|
||||
version_file = os.path.join(parent_dir, 'VERSION')
|
||||
|
||||
with open(version_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"{new_version}\n")
|
||||
|
||||
print(f"✓ Updated {version_file}")
|
||||
|
||||
|
||||
def remind_changelog(parent_dir, new_version, current_version):
|
||||
"""Remind user to update CHANGELOG.md"""
|
||||
changelog_file = os.path.join(parent_dir, 'CHANGELOG.md')
|
||||
|
||||
print(f"\n📝 Don't forget to update {changelog_file}!")
|
||||
print(f"\nAdd your changes under the new version section:")
|
||||
print(f"\n## [{new_version}] - {datetime.now().strftime('%Y-%m-%d')}")
|
||||
print(f"\n### Added")
|
||||
print(f"### Changed")
|
||||
print(f"### Fixed")
|
||||
|
||||
if os.path.exists(changelog_file):
|
||||
print(f"\n💡 Tip: Edit the file now with: nano {changelog_file}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Bump project version number',
|
||||
epilog='Examples:\n %(prog)s\n %(prog)s 2025.12.25',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument(
|
||||
'version',
|
||||
nargs='?',
|
||||
help='Version number in YYYY.MM.DD format (defaults to today\'s date)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-changelog-reminder',
|
||||
action='store_true',
|
||||
help='Skip the changelog reminder'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine new version
|
||||
if args.version:
|
||||
new_version = args.version
|
||||
if not validate_version(new_version):
|
||||
print(f"Error: Invalid version format '{new_version}'")
|
||||
print(f"Expected format: YYYY.MM.DD (e.g., 2025.12.25)")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Use today's date
|
||||
new_version = datetime.now().strftime('%Y.%m.%d')
|
||||
|
||||
# Get parent directory (project root)
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Get current version
|
||||
current_version = get_current_version(parent_dir)
|
||||
|
||||
if current_version:
|
||||
print(f"Current version: {current_version}")
|
||||
|
||||
print(f"New version: {new_version}")
|
||||
|
||||
# Check if version is the same
|
||||
if current_version == new_version:
|
||||
print(f"\n⚠️ Version is already {new_version}")
|
||||
response = input("Continue anyway? [y/N]: ").lower().strip()
|
||||
if response != 'y':
|
||||
print("Aborted.")
|
||||
sys.exit(0)
|
||||
|
||||
# Update files
|
||||
print(f"\nUpdating version files...")
|
||||
update_version_py(parent_dir, new_version)
|
||||
update_version_file(parent_dir, new_version)
|
||||
|
||||
print(f"\n✅ Version bumped to {new_version}")
|
||||
|
||||
# Remind about changelog
|
||||
if not args.no_changelog_reminder:
|
||||
remind_changelog(parent_dir, new_version, current_version)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
88
scripts/generate_sitemap.py
Normal file
88
scripts/generate_sitemap.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sunday Comics - Sitemap generator
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
"""
|
||||
Script to generate a sitemap.xml file for the comic
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||
from xml.dom import minidom
|
||||
|
||||
# Add parent directory to path so we can import comics_data
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from comics_data import COMICS, SITE_URL
|
||||
|
||||
|
||||
def generate_sitemap():
|
||||
"""Generate sitemap.xml from COMICS data"""
|
||||
# Create sitemap root
|
||||
urlset = Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9')
|
||||
|
||||
# Add homepage
|
||||
if COMICS:
|
||||
latest_date = COMICS[-1]['date']
|
||||
url = SubElement(urlset, 'url')
|
||||
SubElement(url, 'loc').text = f'{SITE_URL}/'
|
||||
SubElement(url, 'lastmod').text = latest_date
|
||||
SubElement(url, 'changefreq').text = 'weekly'
|
||||
SubElement(url, 'priority').text = '1.0'
|
||||
|
||||
# Add archive page
|
||||
if COMICS:
|
||||
latest_date = COMICS[-1]['date']
|
||||
url = SubElement(urlset, 'url')
|
||||
SubElement(url, 'loc').text = f'{SITE_URL}/archive'
|
||||
SubElement(url, 'lastmod').text = latest_date
|
||||
SubElement(url, 'changefreq').text = 'weekly'
|
||||
SubElement(url, 'priority').text = '0.9'
|
||||
|
||||
# Add about page
|
||||
url = SubElement(urlset, 'url')
|
||||
SubElement(url, 'loc').text = f'{SITE_URL}/about'
|
||||
SubElement(url, 'changefreq').text = 'monthly'
|
||||
SubElement(url, 'priority').text = '0.7'
|
||||
|
||||
# Add all individual comic pages
|
||||
for comic in COMICS:
|
||||
url = SubElement(urlset, 'url')
|
||||
SubElement(url, 'loc').text = f"{SITE_URL}/comic/{comic['number']}"
|
||||
SubElement(url, 'lastmod').text = comic['date']
|
||||
SubElement(url, 'changefreq').text = 'never'
|
||||
SubElement(url, 'priority').text = '0.8'
|
||||
|
||||
# Convert to pretty XML
|
||||
xml_str = minidom.parseString(tostring(urlset)).toprettyxml(indent=' ')
|
||||
|
||||
# Remove extra blank lines
|
||||
xml_str = '\n'.join([line for line in xml_str.split('\n') if line.strip()])
|
||||
|
||||
return xml_str
|
||||
|
||||
|
||||
def main():
|
||||
"""Generate and save sitemap"""
|
||||
# Get path to static folder
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(script_dir)
|
||||
static_dir = os.path.join(parent_dir, 'static')
|
||||
|
||||
# Create static directory if it doesn't exist
|
||||
os.makedirs(static_dir, exist_ok=True)
|
||||
|
||||
# Generate sitemap
|
||||
sitemap_content = generate_sitemap()
|
||||
|
||||
# Save to file
|
||||
sitemap_file = os.path.join(static_dir, 'sitemap.xml')
|
||||
with open(sitemap_file, 'w', encoding='utf-8') as f:
|
||||
f.write(sitemap_content)
|
||||
|
||||
print(f"Sitemap generated: {sitemap_file}")
|
||||
print(f"Total URLs: {len(COMICS) + 3}") # comics + homepage + archive + about
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
84
scripts/publish_comic.py
Normal file
84
scripts/publish_comic.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sunday Comics - Publish script
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
"""
|
||||
Convenience script to rebuild cache and regenerate all static files.
|
||||
Run this after adding or updating comics.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Add parent directory to path so we can import data_loader
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from data_loader import load_comics_from_yaml, clear_cache
|
||||
|
||||
|
||||
def run_script(script_name, description):
|
||||
"""Run a script and handle errors"""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
script_path = os.path.join(script_dir, script_name)
|
||||
|
||||
print(f"{description}...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_path],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Print only the summary line (last non-empty line)
|
||||
output_lines = [line for line in result.stdout.strip().split('\n') if line.strip()]
|
||||
if output_lines:
|
||||
print(f" ✓ {output_lines[-1]}")
|
||||
else:
|
||||
print(f" ✗ Failed!")
|
||||
if result.stderr:
|
||||
print(f" Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Rebuild cache and regenerate all static files"""
|
||||
print("=" * 60)
|
||||
print("Publishing Comics")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Step 1: Rebuild cache
|
||||
print("1. Rebuilding comics cache...")
|
||||
clear_cache()
|
||||
# Load with cache enabled - since we just cleared it, this will reload from YAML
|
||||
# and automatically save the cache
|
||||
comics = load_comics_from_yaml(use_cache=True)
|
||||
|
||||
if not comics:
|
||||
print(" ✗ No comics found!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" ✓ Cached {len(comics)} comics")
|
||||
print()
|
||||
|
||||
# Step 2: Generate RSS feed
|
||||
success = run_script('generate_rss.py', '2. Generating RSS feed')
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
print()
|
||||
|
||||
# Step 3: Generate sitemap
|
||||
success = run_script('generate_sitemap.py', '3. Generating sitemap')
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("✓ All static files updated successfully!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
38
scripts/rebuild_cache.py
Normal file
38
scripts/rebuild_cache.py
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
# Sunday Comics - Cache rebuild script
|
||||
# Copyright (c) 2025 Tomasita Cabrera
|
||||
# Licensed under the MIT License - see LICENSE file for details
|
||||
|
||||
"""
|
||||
Script to rebuild the comics cache from YAML files.
|
||||
Useful for forcing a fresh cache build.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path so we can import data_loader
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from data_loader import load_comics_from_yaml, clear_cache
|
||||
|
||||
|
||||
def main():
|
||||
"""Rebuild the comics cache"""
|
||||
print("Clearing existing cache...")
|
||||
clear_cache()
|
||||
print()
|
||||
|
||||
print("Rebuilding cache from YAML files...")
|
||||
# Load with cache enabled - since we just cleared it, this will reload from YAML
|
||||
# and automatically save the cache
|
||||
comics = load_comics_from_yaml(use_cache=True)
|
||||
print()
|
||||
|
||||
if comics:
|
||||
print(f"✓ Cache rebuilt successfully with {len(comics)} comics")
|
||||
else:
|
||||
print("✗ No comics found to cache")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -431,6 +431,30 @@ main {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Multi-image comics (webtoon style) */
|
||||
.comic-image-multi {
|
||||
padding: 0; /* Remove padding for seamless stacking */
|
||||
}
|
||||
|
||||
.comic-image-multi img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: bottom; /* Removes tiny gap below images */
|
||||
}
|
||||
|
||||
/* Lazy-loaded images - placeholder while loading */
|
||||
.comic-image-multi img.lazy-load {
|
||||
min-height: 200px;
|
||||
background: var(--color-hover-bg);
|
||||
}
|
||||
|
||||
.comic-image-multi img.lazy-load.loaded {
|
||||
min-height: auto;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Comic Navigation */
|
||||
.comic-navigation {
|
||||
border-top: var(--border-width-thin) solid var(--border-color);
|
||||
@@ -754,7 +778,8 @@ main {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.footer-bottom p {
|
||||
.footer-bottom p,
|
||||
.footer-terms {
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -963,6 +988,18 @@ footer {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-terms {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-md);
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.footer-terms:hover {
|
||||
text-decoration: underline;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Compact Footer Mode */
|
||||
footer.compact-footer {
|
||||
border-top: none;
|
||||
@@ -1055,3 +1092,236 @@ footer.compact-footer .footer-section:not(:last-child)::after {
|
||||
margin-left: var(--space-md);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SHARE/EMBED FEATURE STYLES
|
||||
============================================================ */
|
||||
|
||||
/* Share section (contains permalink and embed buttons) */
|
||||
.comic-share-section {
|
||||
margin-top: var(--space-lg);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Permalink button */
|
||||
.btn-permalink {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background-color: var(--color-background);
|
||||
border: var(--border-width-thin) solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
transition: background-color var(--transition-speed);
|
||||
}
|
||||
|
||||
.btn-permalink:hover {
|
||||
background-color: var(--color-hover-bg);
|
||||
}
|
||||
|
||||
.btn-permalink:focus {
|
||||
outline: 3px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-permalink.copied {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
/* Embed button */
|
||||
.btn-embed {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background-color: var(--color-background);
|
||||
border: var(--border-width-thin) solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
transition: background-color var(--transition-speed);
|
||||
}
|
||||
|
||||
.btn-embed:hover {
|
||||
background-color: var(--color-hover-bg);
|
||||
}
|
||||
|
||||
.btn-embed:focus {
|
||||
outline: 3px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Share button icons */
|
||||
.btn-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.btn-with-icon .btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-permalink.copied .btn-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal {
|
||||
display: none; /* Hidden by default */
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Modal content box */
|
||||
.modal-content {
|
||||
background-color: var(--color-background);
|
||||
margin: 10% auto;
|
||||
padding: 0;
|
||||
border: var(--border-width-thick) solid var(--color-border);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
/* Modal header */
|
||||
.modal-header {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-bottom: var(--border-width-thin) solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
/* Modal close button */
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-close:hover,
|
||||
.modal-close:focus {
|
||||
color: var(--color-text-muted);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Modal body */
|
||||
.modal-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
/* Embed code textarea */
|
||||
#embed-code {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: var(--space-md);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-sm);
|
||||
border: var(--border-width-thin) solid var(--color-border);
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
resize: vertical;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
#embed-code:focus {
|
||||
outline: 3px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Copy button */
|
||||
.btn-copy {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background-color: var(--color-primary);
|
||||
border: var(--border-width-thin) solid var(--color-border);
|
||||
color: var(--color-background);
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
width: 100%;
|
||||
transition: background-color var(--transition-speed);
|
||||
}
|
||||
|
||||
.btn-copy:hover {
|
||||
background-color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-copy:focus {
|
||||
outline: 3px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-copy.copied {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Embed preview link */
|
||||
.embed-preview-link {
|
||||
margin-top: var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.embed-preview-link a {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.embed-preview-link a:hover {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Mobile responsive modal */
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
margin: 20% auto;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
#embed-code {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
BIN
static/images/icons/embed.png
LFS
Normal file
BIN
static/images/icons/embed.png
LFS
Normal file
Binary file not shown.
BIN
static/images/icons/link.png
LFS
Normal file
BIN
static/images/icons/link.png
LFS
Normal file
Binary file not shown.
221
static/js/archive-lazy-load.js
Normal file
221
static/js/archive-lazy-load.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Sunday Comics - Archive Lazy Loading
|
||||
* Implements infinite scroll for the archive page
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let currentPage = 1;
|
||||
let isLoading = false;
|
||||
let hasMore = true;
|
||||
const perPage = 24;
|
||||
|
||||
// Get elements
|
||||
const archiveContent = document.querySelector('.archive-content');
|
||||
if (!archiveContent) return; // Not on archive page
|
||||
|
||||
const totalComics = parseInt(archiveContent.dataset.totalComics || '0');
|
||||
const initialBatch = parseInt(archiveContent.dataset.initialBatch || '24');
|
||||
|
||||
// Calculate if there are more comics to load
|
||||
hasMore = totalComics > initialBatch;
|
||||
|
||||
// Create loading indicator
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'archive-loading';
|
||||
loadingIndicator.innerHTML = '<p>Loading more comics...</p>';
|
||||
loadingIndicator.style.display = 'none';
|
||||
loadingIndicator.style.textAlign = 'center';
|
||||
loadingIndicator.style.padding = '2rem';
|
||||
archiveContent.parentNode.insertBefore(loadingIndicator, archiveContent.nextSibling);
|
||||
|
||||
/**
|
||||
* Load more comics from the API
|
||||
*/
|
||||
async function loadMoreComics() {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
isLoading = true;
|
||||
loadingIndicator.style.display = 'block';
|
||||
|
||||
try {
|
||||
currentPage++;
|
||||
const response = await fetch(`/api/comics?page=${currentPage}&per_page=${perPage}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Add new comics to the DOM
|
||||
appendComics(data.sections);
|
||||
|
||||
// Update state
|
||||
hasMore = data.has_more;
|
||||
|
||||
if (!hasMore) {
|
||||
loadingIndicator.innerHTML = '<p>End of archive</p>';
|
||||
setTimeout(() => {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more comics:', error);
|
||||
loadingIndicator.innerHTML = '<p>Error loading comics. Please try again.</p>';
|
||||
setTimeout(() => {
|
||||
loadingIndicator.style.display = 'none';
|
||||
isLoading = false;
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append comics to the archive
|
||||
* @param {Array} sections - Array of section objects with title and comics
|
||||
*/
|
||||
function appendComics(sections) {
|
||||
const archiveFullWidth = document.querySelector('.archive-content-fullwidth') !== null;
|
||||
const sectionsEnabled = document.querySelector('.section-header') !== null;
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionTitle = section.section_title;
|
||||
const comics = section.comics;
|
||||
|
||||
// Check if we need to create a new section or append to existing
|
||||
let targetGrid;
|
||||
|
||||
if (sectionsEnabled && sectionTitle) {
|
||||
// Check if section already exists
|
||||
const existingSection = findSectionByTitle(sectionTitle);
|
||||
|
||||
if (existingSection) {
|
||||
// Append to existing section grid
|
||||
targetGrid = existingSection.querySelector('.archive-grid');
|
||||
} else {
|
||||
// Create new section
|
||||
const sectionHeader = document.createElement('div');
|
||||
sectionHeader.className = 'section-header';
|
||||
sectionHeader.innerHTML = `<h2>${sectionTitle}</h2>`;
|
||||
archiveContent.appendChild(sectionHeader);
|
||||
|
||||
targetGrid = document.createElement('div');
|
||||
targetGrid.className = 'archive-grid' + (archiveFullWidth ? ' archive-grid-fullwidth' : '');
|
||||
archiveContent.appendChild(targetGrid);
|
||||
}
|
||||
} else {
|
||||
// No sections or no title - use the last grid or create one
|
||||
targetGrid = archiveContent.querySelector('.archive-grid:last-of-type');
|
||||
|
||||
if (!targetGrid) {
|
||||
targetGrid = document.createElement('div');
|
||||
targetGrid.className = 'archive-grid' + (archiveFullWidth ? ' archive-grid-fullwidth' : '');
|
||||
archiveContent.appendChild(targetGrid);
|
||||
}
|
||||
}
|
||||
|
||||
// Add each comic to the grid
|
||||
comics.forEach(comic => {
|
||||
const item = createArchiveItem(comic, archiveFullWidth);
|
||||
targetGrid.appendChild(item);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing section by title
|
||||
* @param {string} title - Section title to find
|
||||
* @returns {Element|null} - The section element or null
|
||||
*/
|
||||
function findSectionByTitle(title) {
|
||||
const sectionHeaders = archiveContent.querySelectorAll('.section-header h2');
|
||||
for (const header of sectionHeaders) {
|
||||
if (header.textContent.trim() === title) {
|
||||
// Return the grid following this header
|
||||
let nextEl = header.parentElement.nextElementSibling;
|
||||
while (nextEl && !nextEl.classList.contains('archive-grid')) {
|
||||
nextEl = nextEl.nextElementSibling;
|
||||
}
|
||||
return nextEl ? nextEl.parentElement : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an archive item element
|
||||
* @param {Object} comic - Comic data
|
||||
* @param {boolean} fullWidth - Whether using full width layout
|
||||
* @returns {Element} - The archive item element
|
||||
*/
|
||||
function createArchiveItem(comic, fullWidth) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'archive-item' + (fullWidth ? ' archive-item-fullwidth' : '');
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `/comic/${comic.number}`;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/static/images/thumbs/${comic.filename}`;
|
||||
img.alt = comic.title || `#${comic.number}`;
|
||||
img.loading = 'lazy';
|
||||
img.onerror = function() {
|
||||
this.onerror = null;
|
||||
this.src = '/static/images/thumbs/default.jpg';
|
||||
};
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'archive-info';
|
||||
|
||||
if (!fullWidth) {
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = `#${comic.number}${comic.title ? ': ' + comic.title : ''}`;
|
||||
info.appendChild(title);
|
||||
}
|
||||
|
||||
const date = document.createElement('p');
|
||||
date.className = 'archive-date';
|
||||
date.textContent = comic.date;
|
||||
info.appendChild(date);
|
||||
|
||||
link.appendChild(img);
|
||||
link.appendChild(info);
|
||||
item.appendChild(link);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has scrolled near the bottom
|
||||
*/
|
||||
function checkScrollPosition() {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const windowHeight = window.innerHeight;
|
||||
const documentHeight = document.documentElement.scrollHeight;
|
||||
|
||||
// Trigger when user is within 1000px of the bottom
|
||||
if (scrollTop + windowHeight >= documentHeight - 1000) {
|
||||
loadMoreComics();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up scroll listener
|
||||
let scrollTimeout;
|
||||
window.addEventListener('scroll', function() {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
scrollTimeout = setTimeout(checkScrollPosition, 100);
|
||||
});
|
||||
|
||||
// Check initial scroll position (in case page is short)
|
||||
setTimeout(checkScrollPosition, 500);
|
||||
|
||||
})();
|
||||
@@ -5,6 +5,7 @@
|
||||
let totalComics = 0;
|
||||
let comicName = ''; // Will be extracted from initial page title
|
||||
let currentComicNumber = 0;
|
||||
let lazyLoadObserver = null;
|
||||
|
||||
// Fetch and display a comic
|
||||
async function loadComic(comicId) {
|
||||
@@ -79,8 +80,15 @@
|
||||
const comicImageDiv = document.querySelector('.comic-image');
|
||||
updateComicImage(comicImageDiv, comic, title);
|
||||
|
||||
// Update or create/remove the link wrapper
|
||||
// Update or create/remove the link wrapper (only for single-image comics)
|
||||
if (!comic.is_multi_image) {
|
||||
updateComicImageLink(comic.number);
|
||||
}
|
||||
|
||||
// Initialize lazy loading for multi-image comics
|
||||
if (comic.is_multi_image) {
|
||||
initLazyLoad();
|
||||
}
|
||||
|
||||
// Update author note
|
||||
let transcriptDiv = document.querySelector('.comic-transcript');
|
||||
@@ -125,6 +133,11 @@
|
||||
// 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) {
|
||||
@@ -132,12 +145,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize lazy loading for multi-image comics
|
||||
function initLazyLoad() {
|
||||
// Disconnect existing observer if any
|
||||
if (lazyLoadObserver) {
|
||||
lazyLoadObserver.disconnect();
|
||||
}
|
||||
|
||||
// Create Intersection Observer for lazy loading
|
||||
lazyLoadObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
const src = img.getAttribute('data-src');
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.removeAttribute('data-src');
|
||||
img.classList.add('loaded');
|
||||
lazyLoadObserver.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '50px' // Start loading 50px before image enters viewport
|
||||
});
|
||||
|
||||
// Observe all lazy-load images
|
||||
document.querySelectorAll('.lazy-load').forEach(img => {
|
||||
lazyLoadObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
// Update or create comic image with optional mobile version
|
||||
function updateComicImage(comicImageDiv, comic, title) {
|
||||
// Clear all existing content
|
||||
comicImageDiv.innerHTML = '';
|
||||
|
||||
// Update container class for multi-image
|
||||
if (comic.is_multi_image) {
|
||||
comicImageDiv.classList.add('comic-image-multi');
|
||||
} else {
|
||||
comicImageDiv.classList.remove('comic-image-multi');
|
||||
}
|
||||
|
||||
// Create new image element(s)
|
||||
if (comic.is_multi_image) {
|
||||
// Multi-image comic (webtoon style)
|
||||
comic.filenames.forEach((filename, index) => {
|
||||
const img = document.createElement('img');
|
||||
|
||||
if (index === 0) {
|
||||
// First image loads immediately
|
||||
img.src = `/static/images/comics/${filename}`;
|
||||
img.loading = 'eager';
|
||||
} else {
|
||||
// Subsequent images lazy load
|
||||
img.setAttribute('data-src', `/static/images/comics/${filename}`);
|
||||
img.classList.add('lazy-load');
|
||||
img.loading = 'lazy';
|
||||
}
|
||||
|
||||
img.alt = comic.alt_texts[index] || '';
|
||||
img.title = comic.alt_texts[index] || '';
|
||||
comicImageDiv.appendChild(img);
|
||||
});
|
||||
} else {
|
||||
// Single image comic
|
||||
if (comic.mobile_filename) {
|
||||
// Create picture element with mobile source
|
||||
const picture = document.createElement('picture');
|
||||
@@ -163,6 +236,7 @@
|
||||
comicImageDiv.appendChild(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update comic image link for click navigation
|
||||
function updateComicImageLink(currentNumber) {
|
||||
@@ -202,10 +276,22 @@
|
||||
firstBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||
firstBtn.onclick = (e) => { e.preventDefault(); loadComic(1); };
|
||||
firstBtn.removeAttribute('aria-disabled');
|
||||
if (firstBtn.tagName === 'SPAN') {
|
||||
firstBtn.setAttribute('tabindex', '0');
|
||||
firstBtn.setAttribute('role', 'button');
|
||||
firstBtn.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadComic(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
firstBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||
firstBtn.onclick = null;
|
||||
firstBtn.onkeydown = null;
|
||||
firstBtn.setAttribute('aria-disabled', 'true');
|
||||
firstBtn.removeAttribute('tabindex');
|
||||
}
|
||||
|
||||
// Previous button
|
||||
@@ -214,10 +300,22 @@
|
||||
prevBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||
prevBtn.onclick = (e) => { e.preventDefault(); loadComic(currentNumber - 1); };
|
||||
prevBtn.removeAttribute('aria-disabled');
|
||||
if (prevBtn.tagName === 'SPAN') {
|
||||
prevBtn.setAttribute('tabindex', '0');
|
||||
prevBtn.setAttribute('role', 'button');
|
||||
prevBtn.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadComic(currentNumber - 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
prevBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||
prevBtn.onclick = null;
|
||||
prevBtn.onkeydown = null;
|
||||
prevBtn.setAttribute('aria-disabled', 'true');
|
||||
prevBtn.removeAttribute('tabindex');
|
||||
}
|
||||
|
||||
// Comic date display
|
||||
@@ -231,10 +329,22 @@
|
||||
nextBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||
nextBtn.onclick = (e) => { e.preventDefault(); loadComic(currentNumber + 1); };
|
||||
nextBtn.removeAttribute('aria-disabled');
|
||||
if (nextBtn.tagName === 'SPAN') {
|
||||
nextBtn.setAttribute('tabindex', '0');
|
||||
nextBtn.setAttribute('role', 'button');
|
||||
nextBtn.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadComic(currentNumber + 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
nextBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||
nextBtn.onclick = null;
|
||||
nextBtn.onkeydown = null;
|
||||
nextBtn.setAttribute('aria-disabled', 'true');
|
||||
nextBtn.removeAttribute('tabindex');
|
||||
}
|
||||
|
||||
// Latest button
|
||||
@@ -243,10 +353,22 @@
|
||||
latestBtn.className = isIconNav ? 'btn-icon-nav' : 'btn btn-nav';
|
||||
latestBtn.onclick = (e) => { e.preventDefault(); loadComic(totalComics); };
|
||||
latestBtn.removeAttribute('aria-disabled');
|
||||
if (latestBtn.tagName === 'SPAN') {
|
||||
latestBtn.setAttribute('tabindex', '0');
|
||||
latestBtn.setAttribute('role', 'button');
|
||||
latestBtn.onkeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
loadComic(totalComics);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
latestBtn.className = isIconNav ? 'btn-icon-nav btn-icon-disabled' : 'btn btn-nav btn-disabled';
|
||||
latestBtn.onclick = null;
|
||||
latestBtn.onkeydown = null;
|
||||
latestBtn.setAttribute('aria-disabled', 'true');
|
||||
latestBtn.removeAttribute('tabindex');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +446,17 @@
|
||||
const dateDisplay = document.querySelector('.comic-date-display');
|
||||
const formattedDate = dateDisplay ? dateDisplay.textContent : null;
|
||||
updateNavButtons(currentNumber, formattedDate);
|
||||
|
||||
// Check if current comic is multi-image
|
||||
const comicImageDiv = document.querySelector('.comic-image');
|
||||
const isMultiImage = comicImageDiv && comicImageDiv.classList.contains('comic-image-multi');
|
||||
|
||||
if (!isMultiImage) {
|
||||
updateComicImageLink(currentNumber);
|
||||
} else {
|
||||
// Initialize lazy loading for multi-image comics on page load
|
||||
initLazyLoad();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle browser back/forward
|
||||
|
||||
129
static/js/embed.js
Normal file
129
static/js/embed.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// Embed functionality for Sunday Comics
|
||||
// Handles showing embed code modal and copying to clipboard
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const modal = document.getElementById('embed-modal');
|
||||
const embedButton = document.getElementById('embed-button');
|
||||
const closeButton = modal ? modal.querySelector('.modal-close') : null;
|
||||
const embedCodeTextarea = document.getElementById('embed-code');
|
||||
const copyButton = document.getElementById('copy-embed-code');
|
||||
const previewLink = document.getElementById('embed-preview-link');
|
||||
|
||||
if (!modal || !embedButton) {
|
||||
// Embed feature not enabled or elements not found
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the site URL from the page (we'll add it as a data attribute)
|
||||
const siteUrl = document.body.getAttribute('data-site-url') || window.location.origin;
|
||||
|
||||
// Open modal when embed button is clicked
|
||||
embedButton.addEventListener('click', function() {
|
||||
const comicNumber = this.getAttribute('data-comic-number');
|
||||
if (!comicNumber) return;
|
||||
|
||||
// Generate embed code
|
||||
const embedUrl = `${siteUrl}/embed/${comicNumber}`;
|
||||
const embedCode = `<iframe src="${embedUrl}" width="800" height="600" frameborder="0" scrolling="no" style="max-width: 100%;" title="Comic #${comicNumber}"></iframe>`;
|
||||
|
||||
// Set the embed code in the textarea
|
||||
embedCodeTextarea.value = embedCode;
|
||||
|
||||
// Set the preview link
|
||||
previewLink.href = embedUrl;
|
||||
|
||||
// Show the modal
|
||||
modal.style.display = 'block';
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
// Focus on the textarea
|
||||
embedCodeTextarea.focus();
|
||||
embedCodeTextarea.select();
|
||||
});
|
||||
|
||||
// Close modal when close button is clicked
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', function() {
|
||||
closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal when clicking outside the modal content
|
||||
modal.addEventListener('click', function(event) {
|
||||
if (event.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal with Escape key
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape' && modal.style.display === 'block') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Copy embed code to clipboard
|
||||
if (copyButton) {
|
||||
copyButton.addEventListener('click', function() {
|
||||
embedCodeTextarea.select();
|
||||
embedCodeTextarea.setSelectionRange(0, 99999); // For mobile devices
|
||||
|
||||
try {
|
||||
// Modern clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(embedCodeTextarea.value).then(function() {
|
||||
showCopyFeedback();
|
||||
}).catch(function() {
|
||||
// Fallback to execCommand
|
||||
fallbackCopy();
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
fallbackCopy();
|
||||
}
|
||||
} catch (err) {
|
||||
fallbackCopy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fallbackCopy() {
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopyFeedback();
|
||||
} catch (err) {
|
||||
alert('Failed to copy. Please select and copy manually.');
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyFeedback() {
|
||||
const originalText = copyButton.textContent;
|
||||
copyButton.textContent = 'Copied!';
|
||||
copyButton.classList.add('copied');
|
||||
|
||||
setTimeout(function() {
|
||||
copyButton.textContent = originalText;
|
||||
copyButton.classList.remove('copied');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.style.display = 'none';
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// Return focus to the embed button
|
||||
if (embedButton) {
|
||||
embedButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Update embed button when comic changes via client-side navigation
|
||||
// This integrates with the existing comic-nav.js functionality
|
||||
window.addEventListener('comicUpdated', function(event) {
|
||||
if (event.detail && event.detail.comicNumber && embedButton) {
|
||||
embedButton.setAttribute('data-comic-number', event.detail.comicNumber);
|
||||
}
|
||||
});
|
||||
})();
|
||||
84
static/js/permalink.js
Normal file
84
static/js/permalink.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// Permalink functionality for Sunday Comics
|
||||
// Handles copying comic permalinks to clipboard
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const permalinkButton = document.getElementById('permalink-button');
|
||||
|
||||
if (!permalinkButton) {
|
||||
// Permalink feature not enabled or button not found
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the site URL from the page
|
||||
const siteUrl = document.body.getAttribute('data-site-url') || window.location.origin;
|
||||
|
||||
// Copy permalink when button is clicked
|
||||
permalinkButton.addEventListener('click', function() {
|
||||
const comicNumber = this.getAttribute('data-comic-number');
|
||||
if (!comicNumber) return;
|
||||
|
||||
// Generate permalink URL
|
||||
const permalink = `${siteUrl}/comic/${comicNumber}`;
|
||||
|
||||
// Copy to clipboard
|
||||
copyToClipboard(permalink);
|
||||
});
|
||||
|
||||
// Copy text to clipboard
|
||||
function copyToClipboard(text) {
|
||||
// Modern clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showCopyFeedback();
|
||||
}).catch(function() {
|
||||
// Fallback for older browsers
|
||||
fallbackCopy(text);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback copy method for older browsers
|
||||
function fallbackCopy(text) {
|
||||
// Create temporary textarea
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopyFeedback();
|
||||
} catch (err) {
|
||||
alert('Failed to copy. Please copy manually: ' + text);
|
||||
}
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
// Show visual feedback that the permalink was copied
|
||||
function showCopyFeedback() {
|
||||
const originalText = permalinkButton.textContent;
|
||||
permalinkButton.textContent = 'Copied!';
|
||||
permalinkButton.classList.add('copied');
|
||||
|
||||
setTimeout(function() {
|
||||
permalinkButton.textContent = originalText;
|
||||
permalinkButton.classList.remove('copied');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Update permalink button when comic changes via client-side navigation
|
||||
window.addEventListener('comicUpdated', function(event) {
|
||||
if (event.detail && event.detail.comicNumber && permalinkButton) {
|
||||
permalinkButton.setAttribute('data-comic-number', event.detail.comicNumber);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -16,20 +16,59 @@ paths:
|
||||
/api/comics:
|
||||
get:
|
||||
summary: Get all comics
|
||||
description: Returns a list of all comics with enriched metadata including formatted dates and author notes
|
||||
description: |
|
||||
Returns all comics with enriched metadata. Supports optional pagination and section grouping.
|
||||
|
||||
**Without pagination parameters:** Returns a simple array of all comics (newest first when using pagination, original order otherwise).
|
||||
|
||||
**With pagination parameters:** Returns paginated response with section grouping (if enabled globally or via `group_by_section` parameter).
|
||||
operationId: getAllComics
|
||||
tags:
|
||||
- Comics
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: Page number for pagination (1-indexed). When provided, triggers paginated response format.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
example: 1
|
||||
- name: per_page
|
||||
in: query
|
||||
description: Number of comics per page (max 100). When provided, triggers paginated response format.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 24
|
||||
example: 24
|
||||
- name: group_by_section
|
||||
in: query
|
||||
description: Force section grouping in response (even when SECTIONS_ENABLED is false). When true, triggers paginated response format.
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
example: false
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response with array of comics
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
oneOf:
|
||||
- type: array
|
||||
description: Simple array response (when no pagination parameters provided)
|
||||
items:
|
||||
$ref: '#/components/schemas/Comic'
|
||||
example:
|
||||
- $ref: '#/components/schemas/PaginatedComicsResponse'
|
||||
examples:
|
||||
simpleArray:
|
||||
summary: Simple array response (default)
|
||||
value:
|
||||
- number: 1
|
||||
title: "First Comic"
|
||||
filename: "comic-001.jpg"
|
||||
@@ -49,6 +88,35 @@ paths:
|
||||
plain: true
|
||||
formatted_date: "Wednesday, January 8, 2025"
|
||||
author_note_is_html: false
|
||||
paginatedResponse:
|
||||
summary: Paginated response (when using page/per_page parameters)
|
||||
value:
|
||||
sections:
|
||||
- section_title: "Chapter 1"
|
||||
comics:
|
||||
- number: 2
|
||||
filename: "comic-002.jpg"
|
||||
date: "2025-01-08"
|
||||
alt_text: "The second comic"
|
||||
full_width: true
|
||||
plain: true
|
||||
formatted_date: "Wednesday, January 8, 2025"
|
||||
author_note_is_html: false
|
||||
- section_title: null
|
||||
comics:
|
||||
- number: 1
|
||||
title: "First Comic"
|
||||
filename: "comic-001.jpg"
|
||||
date: "2025-01-01"
|
||||
alt_text: "The very first comic"
|
||||
full_width: true
|
||||
plain: true
|
||||
formatted_date: "Wednesday, January 1, 2025"
|
||||
author_note_is_html: false
|
||||
page: 1
|
||||
per_page: 24
|
||||
total_comics: 2
|
||||
has_more: false
|
||||
|
||||
/api/comics/{comic_id}:
|
||||
get:
|
||||
@@ -107,6 +175,9 @@ components:
|
||||
- plain
|
||||
- formatted_date
|
||||
- author_note_is_html
|
||||
- filenames
|
||||
- alt_texts
|
||||
- is_multi_image
|
||||
properties:
|
||||
number:
|
||||
type: integer
|
||||
@@ -118,9 +189,20 @@ components:
|
||||
description: Comic title (optional, defaults to "#X" if not provided)
|
||||
example: "First Comic"
|
||||
filename:
|
||||
oneOf:
|
||||
- type: string
|
||||
description: Single image filename in static/images/comics/
|
||||
- type: array
|
||||
description: Multiple image filenames for webtoon-style comics
|
||||
items:
|
||||
type: string
|
||||
description: Image filename in static/images/comics/
|
||||
example: "comic-001.jpg"
|
||||
filenames:
|
||||
type: array
|
||||
description: Normalized array of image filenames (computed field, always an array even for single images)
|
||||
items:
|
||||
type: string
|
||||
example: ["comic-001.jpg"]
|
||||
mobile_filename:
|
||||
type: string
|
||||
description: Optional mobile version of the comic image
|
||||
@@ -131,13 +213,36 @@ components:
|
||||
description: Publication date in YYYY-MM-DD format
|
||||
example: "2025-01-01"
|
||||
alt_text:
|
||||
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
|
||||
description: Accessibility text for the comic image
|
||||
example: "The very first comic"
|
||||
alt_texts:
|
||||
type: array
|
||||
description: Normalized array of alt texts matching filenames (computed field)
|
||||
items:
|
||||
type: string
|
||||
example: ["The very first comic"]
|
||||
is_multi_image:
|
||||
type: boolean
|
||||
description: Indicates if this is a multi-image comic (computed field)
|
||||
example: false
|
||||
author_note:
|
||||
type: string
|
||||
description: Author's note about the comic (plain text or HTML from markdown)
|
||||
example: "This is where your comic journey begins!"
|
||||
author_note_md:
|
||||
type: string
|
||||
description: Filename or path to markdown file for author note
|
||||
example: "2025-01-01.md"
|
||||
section:
|
||||
type: string
|
||||
description: Section/chapter title (appears on archive page when SECTIONS_ENABLED is true)
|
||||
example: "Chapter 1: Origins"
|
||||
full_width:
|
||||
type: boolean
|
||||
description: Whether the comic should display in full-width mode (computed from global default and per-comic override)
|
||||
@@ -155,6 +260,58 @@ components:
|
||||
description: Indicates whether author_note contains HTML (from markdown) or plain text (computed field)
|
||||
example: false
|
||||
|
||||
ComicSection:
|
||||
type: object
|
||||
required:
|
||||
- section_title
|
||||
- comics
|
||||
properties:
|
||||
section_title:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Section/chapter title (null for comics without a section)
|
||||
example: "Chapter 1"
|
||||
comics:
|
||||
type: array
|
||||
description: Comics in this section
|
||||
items:
|
||||
$ref: '#/components/schemas/Comic'
|
||||
|
||||
PaginatedComicsResponse:
|
||||
type: object
|
||||
required:
|
||||
- sections
|
||||
- page
|
||||
- per_page
|
||||
- total_comics
|
||||
- has_more
|
||||
properties:
|
||||
sections:
|
||||
type: array
|
||||
description: Comics grouped by section
|
||||
items:
|
||||
$ref: '#/components/schemas/ComicSection'
|
||||
page:
|
||||
type: integer
|
||||
description: Current page number
|
||||
minimum: 1
|
||||
example: 1
|
||||
per_page:
|
||||
type: integer
|
||||
description: Number of comics per page
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
example: 24
|
||||
total_comics:
|
||||
type: integer
|
||||
description: Total number of comics across all pages
|
||||
minimum: 0
|
||||
example: 100
|
||||
has_more:
|
||||
type: boolean
|
||||
description: Whether there are more pages available
|
||||
example: true
|
||||
|
||||
Error:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
|
||||
<div class="page-header{% if archive_full_width %} page-header-fullwidth{% endif %}">
|
||||
<h1>Comic Archive</h1>
|
||||
<p>Browse all {% set total = namespace(count=0) %}{% for section_title, section_comics in sections %}{% set total.count = total.count + section_comics|length %}{% endfor %}{{ total.count }} comics</p>
|
||||
<p>Browse all {{ total_comics }} comics</p>
|
||||
</div>
|
||||
|
||||
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}">
|
||||
<section class="archive-content{% if archive_full_width %} archive-content-fullwidth{% endif %}"
|
||||
data-total-comics="{{ total_comics }}"
|
||||
data-initial-batch="{{ initial_batch }}">
|
||||
{% for section_title, section_comics in sections %}
|
||||
{% if section_title and sections_enabled %}
|
||||
<div class="section-header">
|
||||
@@ -24,7 +26,8 @@
|
||||
<a href="{{ url_for('comic', comic_id=comic.number) }}">
|
||||
<img src="{{ url_for('static', filename='images/thumbs/' + comic.filename) }}"
|
||||
onerror="this.onerror=null; this.src='{{ url_for('static', filename='images/thumbs/default.jpg') }}';"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}">
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
loading="lazy">
|
||||
<div class="archive-info">
|
||||
{% if not archive_full_width %}
|
||||
<h3>#{{ comic.number }}{% if comic.title %}: {{ comic.title }}{% endif %}</h3>
|
||||
@@ -42,3 +45,7 @@
|
||||
<div class="container"> {# Reopen container for footer #}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/archive-lazy-load.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="{% block meta_description %}A webcomic about life, the universe, and everything{% endblock %}">
|
||||
<link rel="canonical" href="{% block canonical %}{{ site_url }}{{ request.path }}{% endblock %}">
|
||||
|
||||
<!-- Version -->
|
||||
<meta name="generator" content="Sunday Comics {{ version }}">
|
||||
|
||||
<!-- AI Scraping Prevention -->
|
||||
<meta name="robots" content="noai, noimageai">
|
||||
<meta name="googlebot" content="noai, noimageai">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
@@ -32,7 +40,7 @@
|
||||
<link rel="alternate" type="application/rss+xml" title="{{ comic_name }} RSS Feed" href="{{ url_for('static', filename='feed.rss') }}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<body data-site-url="{{ site_url }}">
|
||||
<!-- Skip to main content link for keyboard navigation -->
|
||||
<a href="#main-content" class="skip-to-main">Skip to main content</a>
|
||||
|
||||
@@ -163,6 +171,8 @@
|
||||
<div class="footer-bottom">
|
||||
<p>© {{ current_year }} {{ copyright_name }}. All rights reserved.</p>
|
||||
<span class="footer-divider" aria-hidden="true">|</span>
|
||||
<a href="{{ url_for('terms') }}" class="footer-terms">Terms of Service</a>
|
||||
<span class="footer-divider" aria-hidden="true">|</span>
|
||||
<div class="site-credit">
|
||||
<a href="https://git.puercito.net/mi/sunday" target="_blank" rel="noopener noreferrer" aria-label="Sunday Comics - Webcomic platform">
|
||||
<img src="{{ url_for('static', filename='images/sunday.jpg') }}" alt="Sunday Comics" class="credit-image">
|
||||
@@ -178,7 +188,31 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Embed Code Modal -->
|
||||
{% if embed_enabled %}
|
||||
<div id="embed-modal" class="modal" role="dialog" aria-labelledby="embed-modal-title" aria-hidden="true">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="embed-modal-title">Embed This Comic</h2>
|
||||
<button class="modal-close" aria-label="Close embed modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Copy this code to embed the comic on your website:</p>
|
||||
<textarea id="embed-code" readonly onfocus="this.select()" onclick="this.select()" aria-label="Embed code"></textarea>
|
||||
<button id="copy-embed-code" class="btn-copy" aria-label="Copy embed code to clipboard">Copy Code</button>
|
||||
<p class="embed-preview-link">Preview: <a id="embed-preview-link" href="#" target="_blank" rel="noopener noreferrer">Open embed in new window</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/comic-nav.js') }}"></script>
|
||||
{% if embed_enabled %}
|
||||
<script src="{{ url_for('static', filename='js/embed.js') }}"></script>
|
||||
{% endif %}
|
||||
{% if permalink_enabled %}
|
||||
<script src="{{ url_for('static', filename='js/permalink.js') }}"></script>
|
||||
{% endif %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,27 @@
|
||||
|
||||
{% block og_image %}{{ site_url }}/static/images/thumbs/{{ comic.filename }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ComicStory",
|
||||
"name": "{{ comic.title if comic.title else '#' ~ comic.number }}",
|
||||
"datePublished": "{{ comic.date }}",
|
||||
"image": "{{ site_url }}/static/images/comics/{{ comic.filename }}",
|
||||
"thumbnailUrl": "{{ site_url }}/static/images/thumbs/{{ comic.filename }}",
|
||||
"description": "{{ comic.alt_text }}",
|
||||
"isPartOf": {
|
||||
"@type": "ComicSeries",
|
||||
"name": "{{ comic_name }}",
|
||||
"url": "{{ site_url }}"
|
||||
},
|
||||
"position": "{{ comic.number }}",
|
||||
"url": "{{ site_url }}/comic/{{ comic.number }}"
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- ARIA live region for screen reader announcements -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only" id="comic-announcer"></div>
|
||||
@@ -16,7 +37,18 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="comic-image" id="comic-image-focus" tabindex="-1">
|
||||
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
|
||||
{% if comic.is_multi_image %}
|
||||
{# Multi-image layout (webtoon style) - no click-through on individual images #}
|
||||
{% for i in range(comic.filenames|length) %}
|
||||
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
|
||||
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
|
||||
alt="{{ comic.alt_texts[i] }}"
|
||||
title="{{ comic.alt_texts[i] }}"
|
||||
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Single image with click-through to next comic #}
|
||||
{% if comic.number < total_comics %}
|
||||
<a href="{{ url_for('comic', comic_id=comic.number + 1) }}" aria-label="Click to view next comic">
|
||||
{% if comic.mobile_filename %}
|
||||
@@ -29,7 +61,8 @@
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
title="{{ comic.alt_text }}"
|
||||
loading="eager">
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -43,7 +76,9 @@
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
title="{{ comic.alt_text }}"
|
||||
loading="eager">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -108,6 +143,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if embed_enabled or permalink_enabled %}
|
||||
<div class="comic-share-section">
|
||||
{% if permalink_enabled %}
|
||||
<button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic">
|
||||
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/link.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if embed_enabled %}
|
||||
<button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic">
|
||||
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/embed.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if comic.author_note %}
|
||||
<div class="comic-transcript">
|
||||
<h3>Author Note</h3>
|
||||
|
||||
156
templates/embed.html
Normal file
156
templates/embed.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }} - {{ comic_name }}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.embed-container {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
border: 2px solid #000;
|
||||
background: #fff;
|
||||
}
|
||||
.embed-header {
|
||||
padding: 15px;
|
||||
border-bottom: 2px solid #000;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.embed-header-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.embed-logo {
|
||||
max-width: 120px;
|
||||
max-height: 60px;
|
||||
height: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.embed-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
margin: 0 0 5px 0;
|
||||
color: #000;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.embed-date {
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
.embed-image-wrapper {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
.embed-image-link {
|
||||
display: block;
|
||||
margin: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
.embed-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
}
|
||||
.embed-footer {
|
||||
padding: 12px 15px;
|
||||
font-size: 0.75em;
|
||||
background: #f8f8f8;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
.embed-footer a {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.embed-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.embed-alt-text {
|
||||
display: none;
|
||||
}
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 480px) {
|
||||
.embed-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.embed-logo {
|
||||
max-width: 100px;
|
||||
max-height: 50px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.embed-title {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.embed-date {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.embed-footer {
|
||||
font-size: 0.7em;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="embed-container">
|
||||
<div class="embed-header">
|
||||
<div class="embed-header-text">
|
||||
<h1 class="embed-title">{{ comic.title if comic.title else '#' ~ comic.number }}</h1>
|
||||
<p class="embed-date">{{ comic.formatted_date }}</p>
|
||||
</div>
|
||||
{% if logo_image %}
|
||||
<img src="{{ site_url }}/static/images/{{ logo_image }}" alt="{{ comic_name }}" class="embed-logo">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="embed-image-wrapper">
|
||||
<a href="{{ site_url }}/comic/{{ comic.number }}" class="embed-image-link" target="_blank" rel="noopener noreferrer" aria-label="View {{ comic.title if comic.title else 'comic #' ~ comic.number }} on {{ comic_name }}">
|
||||
{% if comic.mobile_filename %}
|
||||
<picture>
|
||||
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}"
|
||||
class="embed-image">
|
||||
</picture>
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/comics/' + comic.filename) }}"
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}"
|
||||
class="embed-image">
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="embed-footer">
|
||||
<a href="{{ site_url }}/comic/{{ comic.number }}" target="_blank" rel="noopener noreferrer">View on {{ comic_name }} →</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,17 @@
|
||||
<p class="comic-date">{{ comic.date }}</p>
|
||||
</div>
|
||||
|
||||
<div class="comic-image" id="comic-image-focus" tabindex="-1">
|
||||
<div class="comic-image{% if comic.is_multi_image %} comic-image-multi{% endif %}" id="comic-image-focus" tabindex="-1">
|
||||
{% if comic.is_multi_image %}
|
||||
{# Multi-image layout (webtoon style) - no click-through on individual images #}
|
||||
{% for i in range(comic.filenames|length) %}
|
||||
<img src="{% if loop.first %}{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}{% endif %}"
|
||||
{% if not loop.first %}data-src="{{ url_for('static', filename='images/comics/' + comic.filenames[i]) }}" class="lazy-load"{% endif %}
|
||||
alt="{{ comic.alt_texts[i] }}"
|
||||
title="{{ comic.alt_texts[i] }}"
|
||||
loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if comic.mobile_filename %}
|
||||
<picture>
|
||||
<source media="(max-width: 768px)" srcset="{{ url_for('static', filename='images/comics/' + comic.mobile_filename) }}">
|
||||
@@ -29,6 +39,7 @@
|
||||
alt="{{ comic.title if comic.title else '#' ~ comic.number }}"
|
||||
title="{{ comic.alt_text }}">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="comic-navigation">
|
||||
@@ -91,6 +102,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if embed_enabled or permalink_enabled %}
|
||||
<div class="comic-share-section">
|
||||
{% if permalink_enabled %}
|
||||
<button class="btn-permalink{% if use_share_icons %} btn-with-icon{% endif %}" id="permalink-button" data-comic-number="{{ comic.number }}" aria-label="Copy permalink to this comic">
|
||||
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/link.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Copy Permalink
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if embed_enabled %}
|
||||
<button class="btn-embed{% if use_share_icons %} btn-with-icon{% endif %}" id="embed-button" data-comic-number="{{ comic.number }}" aria-label="Get embed code for this comic">
|
||||
{% if use_share_icons %}<img src="{{ url_for('static', filename='images/icons/embed.png') }}" alt="" class="btn-icon" aria-hidden="true">{% endif %}Share/Embed
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if comic.author_note %}
|
||||
<div class="comic-transcript">
|
||||
<h3>Author Note</h3>
|
||||
|
||||
5
version.py
Normal file
5
version.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# Sunday Comics Version
|
||||
# This file contains the version number for the project
|
||||
# Format: YYYY.MM.DD (date-based versioning)
|
||||
|
||||
__version__ = "2025.11.15"
|
||||
Reference in New Issue
Block a user