diff --git a/docs/superpowers/specs/2026-03-22-pages-design.md b/docs/superpowers/specs/2026-03-22-pages-design.md new file mode 100644 index 0000000..cc17417 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-pages-design.md @@ -0,0 +1,148 @@ +# Arbitrary Pages Feature Design + +## Overview + +Allow users to create arbitrary pages for their site. Each page has a title, user-editable slug, markdown body, page type, nav visibility flag, and sort order. Pages are a separate entity from posts with their own admin section and generated site template. Pages rendered at conflicting slugs silently override auto-generated content. + +## Data Layer + +### New `pages` table + +```sql +CREATE TABLE pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, + guid TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + body TEXT NOT NULL, + page_type INTEGER NOT NULL DEFAULT 0, + show_in_nav INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); +CREATE INDEX idx_pages_site ON pages (site_id); +CREATE UNIQUE INDEX idx_pages_guid ON pages (guid); +CREATE UNIQUE INDEX idx_pages_site_slug ON pages (site_id, slug); +``` + +### Model + +```go +type Page struct { + ID int64 + SiteID int64 + GUID string + Title string + Slug string + Body string + PageType int + ShowInNav bool + SortOrder int + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +Page type constants: `PageTypeNormal = 0` (extensible later for archive, search, etc.). + +### SQL queries + +- `SelectPagesOfSite(siteID)` — all pages for a site, ordered by `sort_order ASC` +- `SelectPage(id)` — single page by ID +- `SelectPageByGUID(guid)` — single page by GUID +- `InsertPage` — create new page, returns ID +- `UpdatePage` — update page fields +- `DeletePage(id)` — delete page +- `UpdatePageSortOrder(id, sortOrder)` — update sort order for a single page + +## Admin Section + +### Navigation + +Add "Pages" item to the admin nav bar (`views/_common/nav.html`), linking to `/sites/:siteID/pages`. + +### Routes + +``` +GET /sites/:siteID/pages - List pages +GET /sites/:siteID/pages/new - New page form +GET /sites/:siteID/pages/:pageID - Edit page form +POST /sites/:siteID/pages - Create/update page +DELETE /sites/:siteID/pages/:pageID - Delete page +POST /sites/:siteID/pages/reorder - Update sort order (AJAX) +``` + +### Page list view (`views/pages/index.html`) + +- Lists pages ordered by `sort_order` +- Each row shows title, slug, and nav visibility indicator +- Drag-and-drop reordering via Stimulus + HTML drag API +- On drop, sends new order to `POST /pages/reorder` via AJAX +- "New Page" button + +### Page edit form (`views/pages/edit.html`) + +Two-column layout mirroring the post edit form: + +**Main area (left):** +- Title input +- Body textarea (markdown) + +**Sidebar (right):** +- Slug (editable text input, auto-derived from title via client-side JS, user can override) +- Page Type (select dropdown, just "Normal" for now) +- Show in Nav (checkbox) + +Save button below. + +### Service layer (`services/pages/`) + +- `Service` struct with DB provider dependency +- `CreatePage(ctx, params)` — generates GUID, derives slug from title if not provided, sets timestamps +- `UpdatePage(ctx, params)` — updates fields, sets `updated_at` +- `DeletePage(ctx, pageID)` — deletes page +- `ListPages(ctx)` — returns all pages for the site from context, ordered by `sort_order` +- `GetPage(ctx, pageID)` — returns single page +- `ReorderPages(ctx, pageIDs []int64)` — accepts ordered list of page IDs, updates `sort_order` for each (sort_order = index in list) + +### Handler (`handlers/pages.go`) + +- `PagesHandler` struct with `PageService` +- Standard CRUD handlers following the existing posts handler pattern +- `Reorder` handler accepts JSON array of page IDs, calls `ReorderPages` + +## Generated Site + +### Template + +New template `pages_single.html` — receives rendered page HTML, rendered inside `layout_main.html` (same wrapping as posts). + +Template data: +```go +type pageSingleData struct { + commonData + Page *models.Page + HTML template.HTML +} +``` + +### Builder changes + +New method `renderPages` on the builder: +- Iterates all pages from `pubmodel.Site.Pages` +- For each page, renders markdown body and writes to the page's slug path using `createAtPath` +- Pages are rendered **after** all other content (posts, post lists, categories, feeds, uploads, static assets) +- This ensures pages at conflicting slugs silently overwrite auto-generated content +- Implementation: `renderPages` runs as a sequential step after `eg.Wait()` returns in `BuildSite` + +### Publisher changes + +- `pubmodel.Site` gets a new `Pages []models.Page` field +- The publisher fetches all pages for the site via `SelectPagesOfSite` and populates this field + +## Approach + +Pages are a separate entity from posts with their own table, service, handler, and templates. The override mechanism is file-system-based: the site builder renders pages last, so any page slug that conflicts with an auto-generated path wins by overwriting the file. The `show_in_nav` field is stored and editable in admin but not yet consumed by the generated site layout — that integration is deferred for a future change.