Files
summercms-initial-research/.planning/phases/10-core-plugins/10-04-PLAN.md
Jakub Zych 4d8d5719d5 docs(10): create phase plan
Phase 10: Core Plugins
- 4 plans in 2 waves
- Wave 1: 10-01 (User auth), 10-03 (Blog posts) - parallel
- Wave 2: 10-02 (User profiles), 10-04 (Blog categories/tags) - sequential
- Ready for execution
2026-02-05 16:07:32 +01:00

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
10-core-plugins 04 execute 2
10-03
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
true
truths artifacts key_links
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
path provides contains
plugins/golem15/blog/models/Category.scala Hierarchical category model with nested set case class Category
path provides contains
plugins/golem15/blog/models/Tag.scala Tag model for free-form labeling case class Tag
path provides exports
plugins/golem15/blog/components/Posts.scala Frontend post listing component
PostsComponent
onLoadMore
path provides exports
plugins/golem15/blog/components/Post.scala Single post display component
PostComponent
path provides contains
plugins/golem15/blog/resources/db/migration/V10_2_2__blog_categories_tags.sql Categories, tags, and pivot tables CREATE TABLE blog_categories
from to via pattern
plugins/golem15/blog/components/Posts.scala BlogPostService post listing postService.listFrontend
from to via pattern
plugins/golem15/blog/services/BlogPostService.scala CategoryService category filtering categoryService.getAllChildrenAndSelf
from to via pattern
plugins/golem15/blog/services/CategoryService.scala NestedTree tree operations 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

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>

@.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
<div class="blog-posts" id="posts-{{ __SELF__ }}">
  {% if posts.items is empty %}
    <div class="no-posts">{{ noPostsMessage }}</div>
  {% else %}
    <div class="posts-list">
      {% for post in posts.items %}
        {% include "posts/item" %}
      {% endfor %}
    </div>

    {% if posts.hasNext %}
      <div class="load-more">
        <button
          hx-post="{{ componentHandler('onLoadMore') }}"
          hx-vals='{"page": {{ posts.currentPage + 1 }}}'
          hx-target="#posts-{{ __SELF__ }} .posts-list"
          hx-swap="beforeend"
          hx-indicator=".htmx-indicator">
          Load More
          <span class="htmx-indicator">Loading...</span>
        </button>
      </div>
    {% endif %}
  {% endif %}
</div>
```

**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

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/10-core-plugins/10-04-SUMMARY.md`