feat(tags): Add tag archive route and admin interface integration

Implement Phase 3 of v1.3.0 tags feature per microformats-tags-design.md:

Routes (starpunk/routes/public.py):
- Add /tag/<tag> archive route with normalization and 404 handling
- Pre-load tags in index route for all notes
- Pre-load tags in note route for individual notes

Admin (starpunk/routes/admin.py):
- Parse comma-separated tag input in create route
- Parse tag input in update route
- Pre-load tags when displaying edit form
- Empty tag field removes all tags

Templates:
- Add tag input field to templates/admin/edit.html
- Add tag input field to templates/admin/new.html
- Use Jinja2 map filter to display existing tags

Implementation details:
- Tag URL parameter normalized to lowercase before lookup
- Tags pre-loaded using object.__setattr__ pattern (like media)
- parse_tag_input() handles trim, dedupe, normalization
- All existing tests pass (micropub categories, admin routes)

Per architect design:
- No pagination on tag archives (acceptable for v1.3.0)
- No autocomplete in admin (out of scope)
- Follows existing media loading patterns

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-10 11:42:16 -07:00
parent 377027e79a
commit 372064b116
41 changed files with 2573 additions and 10573 deletions

View File

@@ -78,6 +78,7 @@ def create_note_submit():
custom_slug: Optional custom slug (v1.2.0 Phase 1)
media_files: Multiple file upload (v1.2.0 Phase 3)
captions[]: Captions for each media file (v1.2.0 Phase 3)
tags: Comma-separated tag list (v1.3.0 Phase 3)
Returns:
Redirect to dashboard on success, back to form on error
@@ -85,21 +86,27 @@ def create_note_submit():
Decorator: @require_auth
"""
from starpunk.media import save_media, attach_media_to_note
from starpunk.tags import parse_tag_input
content = request.form.get("content", "").strip()
published = "published" in request.form
custom_slug = request.form.get("custom_slug", "").strip()
tags_input = request.form.get("tags", "")
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.new_note_form"))
# Parse tags (v1.3.0 Phase 3)
tags = parse_tag_input(tags_input)
try:
# Create note first (per Q4)
note = create_note(
content,
published=published,
custom_slug=custom_slug if custom_slug else None
custom_slug=custom_slug if custom_slug else None,
tags=tags if tags else None
)
# Handle media uploads (v1.2.0 Phase 3)
@@ -167,12 +174,18 @@ def edit_note_form(note_id: int):
Decorator: @require_auth
Template: templates/admin/edit.html
"""
from starpunk.tags import get_note_tags
note = get_note(id=note_id)
if not note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
# Pre-load tags for the edit form (v1.3.0 Phase 3)
tags = get_note_tags(note.id)
object.__setattr__(note, '_cached_tags', tags)
return render_template("admin/edit.html", note=note)
@@ -191,12 +204,15 @@ def update_note_submit(note_id: int):
Form data:
content: Updated markdown content (required)
published: Checkbox for published status (optional)
tags: Comma-separated tag list (v1.3.0 Phase 3)
Returns:
Redirect to dashboard on success, back to form on error
Decorator: @require_auth
"""
from starpunk.tags import parse_tag_input
# Check if note exists first
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
@@ -205,13 +221,22 @@ def update_note_submit(note_id: int):
content = request.form.get("content", "").strip()
published = "published" in request.form
tags_input = request.form.get("tags", "")
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
# Parse tags (v1.3.0 Phase 3)
tags = parse_tag_input(tags_input)
try:
note = update_note(id=note_id, content=content, published=published)
note = update_note(
id=note_id,
content=content,
published=published,
tags=tags if tags else None
)
flash(f"Note updated: {note.slug}", "success")
return redirect(url_for("admin.dashboard"))
except ValueError as e: