---
phase: 10-core-plugins
plan: 04
type: execute
wave: 2
depends_on: ["10-03"]
files_modified:
- plugins/golem15/blog/models/Category.scala
- plugins/golem15/blog/models/Tag.scala
- plugins/golem15/blog/repositories/CategoryRepository.scala
- plugins/golem15/blog/repositories/TagRepository.scala
- plugins/golem15/blog/services/CategoryService.scala
- plugins/golem15/blog/services/TagService.scala
- plugins/golem15/blog/services/BlogPostService.scala
- plugins/golem15/blog/controllers/Categories.scala
- plugins/golem15/blog/components/Posts.scala
- plugins/golem15/blog/components/Post.scala
- plugins/golem15/blog/components/Categories.scala
- plugins/golem15/blog/components/RelatedPosts.scala
- plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql
- plugins/golem15/blog/resources/controllers/categories/fields.yaml
- plugins/golem15/blog/resources/views/posts/default.peb
- plugins/golem15/blog/resources/views/posts/item.peb
- plugins/golem15/blog/resources/views/post/default.peb
autonomous: true
must_haves:
truths:
- "Blog posts can be assigned to categories"
- "Categories are hierarchical (parent/child)"
- "Blog posts can have multiple tags"
- "Frontend displays post listings with pagination"
- "Frontend displays individual post pages"
- "Related posts shown based on shared categories/tags"
artifacts:
- path: "plugins/golem15/blog/models/Category.scala"
provides: "Hierarchical category model with nested set"
contains: "case class Category"
- path: "plugins/golem15/blog/models/Tag.scala"
provides: "Tag model for free-form labeling"
contains: "case class Tag"
- path: "plugins/golem15/blog/components/Posts.scala"
provides: "Frontend post listing component"
exports: ["PostsComponent", "onLoadMore"]
- path: "plugins/golem15/blog/components/Post.scala"
provides: "Single post display component"
exports: ["PostComponent"]
- path: "plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql"
provides: "Categories, tags, and pivot tables"
contains: "CREATE TABLE blog_categories"
key_links:
- from: "plugins/golem15/blog/components/Posts.scala"
to: "BlogPostService"
via: "post listing"
pattern: "postService\\.listFrontend"
- from: "plugins/golem15/blog/services/BlogPostService.scala"
to: "CategoryService"
via: "category filtering"
pattern: "categoryService\\.getAllChildrenAndSelf"
- from: "plugins/golem15/blog/services/CategoryService.scala"
to: "NestedTree"
via: "tree operations"
pattern: "NestedTree\\."
---
Complete the Blog plugin with categories, tags, and frontend listing components.
Purpose: Enable content organization and frontend display, demonstrating hierarchical data structures and frontend component patterns.
Output: Complete Blog plugin with:
- Hierarchical categories using nested set model
- Tags for free-form labeling
- Frontend Posts component with pagination/infinite scroll
- Frontend Post component for single post display
- Related posts based on shared categories/tags
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/10-core-plugins/10-CONTEXT.md
@.planning/phases/10-core-plugins/10-RESEARCH.md
# Prior plan in this phase
@.planning/phases/10-core-plugins/10-03-SUMMARY.md
# Reference: Phase 3 component patterns for HTMX
Task 1: Category and tag models with services
plugins/golem15/blog/models/Category.scala
plugins/golem15/blog/models/Tag.scala
plugins/golem15/blog/models/PostCategory.scala
plugins/golem15/blog/models/PostTag.scala
plugins/golem15/blog/repositories/CategoryRepository.scala
plugins/golem15/blog/repositories/TagRepository.scala
plugins/golem15/blog/services/CategoryService.scala
plugins/golem15/blog/services/TagService.scala
plugins/golem15/blog/services/NestedTree.scala
plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql
Create category and tag models with nested tree support:
**Category.scala (research Pattern 6):**
- Case class with fields:
id, name, slug (unique), description (Option), parentId (Option),
nestLeft (Int), nestRight (Int), nestDepth (Int),
published, publishedAt, createdAt, updatedAt
- Quill table mapping to `blog_categories`
- CategoryTreeNode case class: category, children (List), postCount
**Tag.scala (research Pattern 7):**
- Case class: id, name, slug (unique), createdAt, updatedAt
- Quill table mapping to `blog_tags`
- TagWithCount case class: tag, postCount
**PostCategory.scala & PostTag.scala:**
- Pivot case classes for many-to-many relationships
- PostCategory: postId, categoryId
- PostTag: postId, tagId
**NestedTree.scala (utility object):**
- Functions for nested set operations:
- rebuild(categories: List[Category]): List[Category] - Recalculate left/right
- descendants(category, all): List[Category] - Get all children
- ancestors(category, all): List[Category] - Get all parents
- moveNode(category, newParent, all): List[Category] - Move category in tree
- insertNode(category, parent, all): List[Category] - Add new category
- deleteNode(category, all): List[Category] - Remove category and adjust tree
**CategoryRepository.scala:**
- Trait:
- findById(id): Option[Category]
- findBySlug(slug): Option[Category]
- listAll: List[Category]
- create(category): Category
- update(category): Category
- delete(id): Unit
- getPostCount(categoryId): Int
- getNestedPostCount(categoryId): Int (includes children)
- Live implementation using QuillContext
**TagRepository.scala:**
- Trait:
- findById(id): Option[Tag]
- findBySlug(slug): Option[Tag]
- findByName(name): Option[Tag]
- listAll: List[Tag]
- create(tag): Tag
- listForPost(postId): List[Tag]
- getPopular(limit): List[TagWithCount]
- Live implementation
**CategoryService.scala:**
- Trait:
- create(data): Category (auto-generate slug, rebuild tree)
- update(id, data): Category (rebuild tree if parent changed)
- delete(id): Unit (rebuild tree after)
- findBySlug(slug): Option[Category]
- listAll: List[Category]
- getTree: List[CategoryTreeNode]
- getChildren(parentId): List[Category]
- getAllChildrenAndSelf(categoryId): List[Category] (for nested queries)
- getPostCount(categoryId): Int
- getNestedPostCount(categoryId): Int
- Live implementation with NestedTree for tree operations
**TagService.scala:**
- Trait:
- findOrCreate(name): Tag (create with auto-slug if not exists)
- findBySlug(slug): Option[Tag]
- listAll: List[Tag]
- listForPost(postId): List[Tag]
- getPopularTags(limit): List[TagWithCount]
- syncPostTags(postId, tagNames): List[Tag] (update pivot table)
- Live implementation
**V10_2_2__blog_categories_tags.sql:**
- CREATE TABLE blog_categories (all fields, nested set indexes)
- CREATE TABLE blog_tags (id, name, slug unique, timestamps)
- CREATE TABLE blog_posts_categories (post_id, category_id, composite PK, FKs)
- CREATE TABLE blog_posts_tags (post_id, tag_id, composite PK, FKs)
- ALTER TABLE blog_posts ADD CONSTRAINT fk_main_category REFERENCES blog_categories
- Indexes on slugs, nested set columns, pivot table lookups
./mill summercms.compile succeeds
NestedTree rebuild produces valid left/right sequence
SQL migration syntax valid
Category model with nested set tree support.
Tag model for free-form labeling.
Services handle CRUD with automatic tree maintenance.
Pivot tables link posts to categories and tags.
Task 2: Update BlogPostService for relations
plugins/golem15/blog/services/BlogPostService.scala (modify)
plugins/golem15/blog/repositories/BlogPostRepository.scala (modify)
plugins/golem15/blog/controllers/Posts.scala (modify)
plugins/golem15/blog/controllers/Categories.scala
plugins/golem15/blog/resources/controllers/posts/fields.yaml (modify)
plugins/golem15/blog/resources/controllers/categories/fields.yaml
plugins/golem15/blog/resources/controllers/categories/columns.yaml
Integrate categories and tags with blog posts:
**BlogPostRepository.scala additions:**
- findBySlugWithRelations(slug): Option[BlogPostWithRelations]
- listFrontendWithRelations(options): PaginatedResult[BlogPostWithRelations]
- listByCategory(categoryId, options): PaginatedResult[BlogPostWithRelations]
- listByTag(tagSlug, options): PaginatedResult[BlogPostWithRelations]
- getRelatedPosts(postId, limit): List[BlogPostWithRelations]
- Join queries to load categories, tags, author in single query
**BlogPostService.scala modifications:**
- BlogPostWithRelations case class:
post: BlogPost, categories: List[Category], tags: List[Tag],
featuredImages: List[MediaItem], author: Option[BackendUser],
readingTime: Int
- Update create/update to handle categories and tags:
- PostCreateData/UpdateData add: categoryIds (List[Long]), tagNames (List[String])
- On save: Sync pivot tables via services
- Set mainCategoryId if categories provided
- Update listFrontend to use listFrontendWithRelations
- Add listByCategory(categoryId, options)
- Add listByTag(tagSlug, options)
- Add getRelatedPosts(postId, limit):
- Score = 2 * shared_categories + 1 * shared_tags
- Exclude current post
- Return top N by score
**Posts.scala controller modifications:**
- Update store/update actions to handle categories and tags from form
- Add category/tag selection to form context
**Categories.scala controller (new):**
- Admin controller for category management
- Actions: index, create, store, edit, update, delete, reorder (tree)
- HTMX handler for drag-drop tree reordering
**fields.yaml modifications (Posts):**
- Add categories tab fields:
```yaml
_categories:
type: relation
label: Categories
nameFrom: name
list: true
tree: true
tab: Categories
_tags:
type: taglist
label: Tags
mode: array
separator: ','
tab: Tags
```
**categories/fields.yaml:**
```yaml
fields:
name:
type: text
label: Name
required: true
slug:
type: text
label: Slug
preset:
field: name
type: slug
parent_id:
type: relation
label: Parent Category
emptyOption: -- None --
nameFrom: name
description:
type: textarea
label: Description
size: small
published:
type: checkbox
label: Published
```
**categories/columns.yaml:**
```yaml
columns:
name:
label: Name
sortable: true
tree: true
slug:
label: Slug
published:
label: Published
type: switch
post_count:
label: Posts
type: number
```
**Register Categories controller in Plugin.scala**
./mill summercms.compile succeeds
Related posts algorithm returns posts with shared categories/tags
Category tree drag-drop works in admin
Posts can be assigned categories and tags.
Admin can manage categories in tree view.
Related posts calculated by shared categories/tags.
Post queries include relations efficiently.
Task 3: Frontend listing components
plugins/golem15/blog/components/Posts.scala
plugins/golem15/blog/components/Post.scala
plugins/golem15/blog/components/Categories.scala
plugins/golem15/blog/components/RelatedPosts.scala
plugins/golem15/blog/resources/views/posts/default.peb
plugins/golem15/blog/resources/views/posts/item.peb
plugins/golem15/blog/resources/views/post/default.peb
plugins/golem15/blog/resources/views/categories/default.peb
plugins/golem15/blog/resources/views/relatedposts/default.peb
plugins/golem15/blog/Plugin.scala (modify)
Create frontend components for blog display:
**Posts.scala (research Pattern 8):**
- Extend SummerComponent trait
- ComponentDetails: name "Posts", description "Blog post listing"
- Property schema:
- pageNumber: String, URL param for pagination, default "{{ :page }}"
- categoryFilter: String, filter by category slug
- postsPerPage: String, default "10", validation 1-100
- noPostsMessage: String, default "No posts found."
- sortOrder: Dropdown with BlogPost.allowedSortingOptions
- throwNotFound: Checkbox, 404 on empty results
- Page variables: posts (PaginatedResult), category (Option), noPostsMessage
- onRun lifecycle:
- Load category if categoryFilter set
- Build PostListOptions from properties
- Call postService.listFrontend or listByCategory
- Handle empty results per throwNotFound
- Set page context variables
- onLoadMore HTMX handler:
- Parse page number from form
- Load next page of posts
- Return items partial
- Trigger "postsExhausted" when no more
**Post.scala component:**
- ComponentDetails: name "Post", description "Single post display"
- Property schema:
- slug: String, URL param for post slug, default "{{ :slug }}"
- notFoundMessage: String
- Page variables: post (BlogPostWithRelations)
- onRun lifecycle:
- Get slug from URL
- Call postService.findBySlug
- 404 if not found
- Set page context: post, author, categories, tags, readingTime
- Set OpenGraph meta tags for social sharing
**Categories.scala component:**
- ComponentDetails: name "Categories", description "Category listing/tree"
- Property schema:
- displayMode: Dropdown (tree/flat), default "tree"
- showPostCount: Checkbox, default true
- includeEmpty: Checkbox, show categories with no posts
- Page variables: categories (tree or flat list)
- onRun: Load category tree, filter empty if needed
**RelatedPosts.scala component:**
- ComponentDetails: name "RelatedPosts", description "Related posts by category/tag"
- Property schema:
- limit: String, max posts to show, default "3"
- postSlug: String, current post slug (from context)
- Page variables: relatedPosts (List)
- onRun: Call postService.getRelatedPosts, set context
**Templates:**
**posts/default.peb (research code example):**
```html
{% if posts.items is empty %}
{{ noPostsMessage }}
{% else %}
{% for post in posts.items %}
{% include "posts/item" %}
{% endfor %}
{% if posts.hasNext %}
{% endif %}
{% endif %}
```
**posts/item.peb:**
- Article element with post data
- Featured image (lazy load)
- Title as link to post page
- Excerpt or auto-generated summary
- Author name, published date, reading time
- Category and tag links
**post/default.peb:**
- Full post display
- OpenGraph meta tags in head
- Title, featured image, author, date
- Content HTML
- Categories and tags
- Share buttons (copy link, Twitter, Facebook)
- Include RelatedPosts component placeholder
**categories/default.peb:**
- Tree or flat list based on displayMode
- Category name as link
- Post count if showPostCount
**relatedposts/default.peb:**
- Grid of related post cards
- Image, title, excerpt snippet
**Register all components in Plugin.scala boot method**
./mill summercms.compile succeeds
Components registered in Plugin.scala
Templates have valid Pebble syntax
HTMX attributes properly formatted
Posts component displays paginated listings with infinite scroll.
Post component displays single post with meta tags.
Categories component shows category tree/list.
RelatedPosts component shows related content.
All templates render with HTMX interactions.
After all tasks complete:
1. Blog plugin compiles: `./mill summercms.compile`
2. Migration creates all tables: blog_categories, blog_tags, pivot tables
3. Category nested tree rebuilds correctly on insert/delete
4. Posts component loads and paginates
5. Post component displays with OpenGraph meta
6. Related posts algorithm returns relevant content
- Categories form hierarchical tree structure
- Nested set left/right values correct after operations
- Tags can be created on-the-fly when typing
- Post admin form shows category tree selector and tag input
- Frontend /blog shows post listing with load more
- Frontend /blog/:slug shows full post with related posts
- Category pages filter posts by category and children
- Tag pages filter posts by tag