| + | Title | +Slug | +Nav | +
|---|---|---|---|
| ☰ | +{{ .Title }} | +{{ .Slug }} |
+ {{ if .ShowInNav }}Yes{{ end }} | +
diff --git a/assets/js/controllers/pagelist.js b/assets/js/controllers/pagelist.js new file mode 100644 index 0000000..7da6872 --- /dev/null +++ b/assets/js/controllers/pagelist.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" +import { showToast } from "../services/toast"; + +export default class PagelistController extends Controller { + static values = { + siteId: Number, + }; + + static targets = ["list"]; + + dragStart(ev) { + this.draggedRow = ev.currentTarget; + ev.currentTarget.classList.add("opacity-50"); + ev.dataTransfer.effectAllowed = "move"; + } + + dragOver(ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + } + + drop(ev) { + ev.preventDefault(); + const targetRow = ev.currentTarget; + if (this.draggedRow && this.draggedRow !== targetRow) { + const rows = [...this.listTarget.children]; + const draggedIdx = rows.indexOf(this.draggedRow); + const targetIdx = rows.indexOf(targetRow); + if (draggedIdx < targetIdx) { + targetRow.after(this.draggedRow); + } else { + targetRow.before(this.draggedRow); + } + this.saveOrder(); + } + } + + dragEnd(ev) { + ev.currentTarget.classList.remove("opacity-50"); + this.draggedRow = null; + } + + async saveOrder() { + const rows = [...this.listTarget.children]; + const pageIds = rows.map(row => parseInt(row.dataset.pageId, 10)); + + try { + await fetch(`/sites/${this.siteIdValue}/pages/reorder`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ page_ids: pageIds }), + }); + } catch (error) { + showToast({ + title: "Error", + body: "Failed to reorder pages.", + }); + } + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d76c353..28451fb 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -7,6 +7,7 @@ import LogoutController from "./controllers/logout"; import FirstRunController from "./controllers/firstrun"; import UploadController from "./controllers/upload"; import ShowUploadController from "./controllers/show_upload"; +import PagelistController from "./controllers/pagelist"; window.Stimulus = Application.start() Stimulus.register("toast", ToastController); @@ -15,4 +16,5 @@ Stimulus.register("postedit", PosteditController); Stimulus.register("logout", LogoutController); Stimulus.register("first-run", FirstRunController); Stimulus.register("upload", UploadController); -Stimulus.register("show-upload", ShowUploadController); \ No newline at end of file +Stimulus.register("show-upload", ShowUploadController); +Stimulus.register("pagelist", PagelistController); \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 56517e7..89310bd 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -113,6 +113,7 @@ Starting weiro without any arguments will start the server. uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} + pgh := handlers.PagesHandler{PageService: svcs.Pages} app.Get("/login", lh.Login) app.Post("/login", lh.DoLogin) @@ -149,6 +150,14 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/categories/:categoryID", ch.Update) siteGroup.Post("/categories/:categoryID/delete", ch.Delete) + siteGroup.Get("/pages", pgh.Index) + siteGroup.Get("/pages/new", pgh.New) + siteGroup.Get("/pages/:pageID", pgh.Edit) + siteGroup.Post("/pages", pgh.Create) + siteGroup.Post("/pages/reorder", pgh.Reorder) + siteGroup.Post("/pages/:pageID", pgh.Update) + siteGroup.Post("/pages/:pageID/delete", pgh.Delete) + app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) app.Get("/first-run", ih.FirstRun) app.Post("/first-run", ih.FirstRunSubmit) diff --git a/docs/superpowers/plans/2026-03-22-pages.md b/docs/superpowers/plans/2026-03-22-pages.md new file mode 100644 index 0000000..89a3983 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-pages.md @@ -0,0 +1,1218 @@ +# Arbitrary Pages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to create arbitrary pages with title, slug, markdown body, page type, nav visibility, and sort order, rendered on the generated site. + +**Architecture:** New `pages` table + model + service + handler + admin views following the existing categories pattern. Publisher populates `pubmodel.Site.Pages`, and the site builder renders pages **after** all other content so conflicting slugs silently override auto-generated files. Drag-and-drop reordering in admin via a new Stimulus controller. + +**Tech Stack:** Go/Fiber v3, SQLite/sqlc, Bootstrap 5, Stimulus.js, goldmark markdown, html/template + +--- + +## File Structure + +**New files:** +- `sql/schema/06_pages.up.sql` - Migration for pages table +- `sql/queries/pages.sql` - sqlc queries for pages +- `models/pages.go` - Page model struct and slug helper +- `providers/db/pages.go` - DB provider methods for pages +- `services/pages/service.go` - Pages service layer +- `handlers/pages.go` - Admin pages handler +- `views/pages/index.html` - Admin page list with drag-and-drop +- `views/pages/edit.html` - Admin page edit form (two-column) +- `assets/js/controllers/pagelist.js` - Stimulus controller for drag-and-drop reorder +- `layouts/simplecss/templates/pages_single.html` - Generated site page template +- `providers/sitebuilder/render_pages.go` - Builder renderPages method + +**Modified files:** +- `providers/db/gen/sqlgen/` - Regenerated sqlc output +- `models/pubmodel/sites.go` - Add `Pages []models.Page` field +- `services/publisher/service.go` - Fetch pages and populate pubmodel +- `providers/sitebuilder/tmpls.go` - Add pageSingleData type and template constant +- `providers/sitebuilder/builder.go` - Call renderPages after eg.Wait() +- `providers/sitebuilder/builder_test.go` - Add pages to test +- `views/_common/nav.html` - Add "Pages" nav item +- `services/services.go` - Wire up pages service +- `cmds/server.go` - Wire up pages handler and routes +- `assets/js/main.js` - Register pagelist controller +- `esbuild.mjs` - No change needed (auto-picks up new JS files) + +--- + +### Task 1: Schema Migration and sqlc Queries + +**Files:** +- Create: `sql/schema/06_pages.up.sql` +- Create: `sql/queries/pages.sql` +- Regenerate: `providers/db/gen/sqlgen/` + +- [ ] **Step 1: Write the schema migration** + +```sql +-- sql/schema/06_pages.up.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); +``` + +- [ ] **Step 2: Write the sqlc queries** + +```sql +-- sql/queries/pages.sql + +-- name: SelectPagesOfSite :many +SELECT * FROM pages +WHERE site_id = ? ORDER BY sort_order ASC; + +-- name: SelectPage :one +SELECT * FROM pages WHERE id = ? LIMIT 1; + +-- name: SelectPageByGUID :one +SELECT * FROM pages WHERE guid = ? LIMIT 1; + +-- name: SelectPageBySlugAndSite :one +SELECT * FROM pages WHERE site_id = ? AND slug = ? LIMIT 1; + +-- name: InsertPage :one +INSERT INTO pages ( + site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id; + +-- name: UpdatePage :exec +UPDATE pages SET + title = ?, + slug = ?, + body = ?, + page_type = ?, + show_in_nav = ?, + updated_at = ? +WHERE id = ?; + +-- name: UpdatePageSortOrder :exec +UPDATE pages SET sort_order = ? WHERE id = ?; + +-- name: DeletePage :exec +DELETE FROM pages WHERE id = ?; +``` + +- [ ] **Step 3: Regenerate sqlc** + +Run: `sqlc generate` +Expected: Clean generation, new files in `providers/db/gen/sqlgen/` for pages queries. + +- [ ] **Step 4: Commit** + +```bash +git add sql/ providers/db/gen/ +git commit -m "feat(pages): add pages table schema and sqlc queries" +``` + +--- + +### Task 2: Page Model + +**Files:** +- Create: `models/pages.go` + +- [ ] **Step 1: Write the Page model and constants** + +```go +// models/pages.go +package models + +import ( + "strings" + "time" + "unicode" +) + +const ( + PageTypeNormal = 0 +) + +type Page struct { + ID int64 `json:"id"` + SiteID int64 `json:"site_id"` + GUID string `json:"guid"` + Title string `json:"title"` + Slug string `json:"slug"` + Body string `json:"body"` + PageType int `json:"page_type"` + ShowInNav bool `json:"show_in_nav"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GeneratePageSlug creates a URL-safe slug from a page title. +// e.g. "About Me" -> "about-me" +func GeneratePageSlug(title string) string { + var sb strings.Builder + prevDash := false + for _, c := range strings.TrimSpace(title) { + if unicode.IsLetter(c) || unicode.IsNumber(c) { + sb.WriteRune(unicode.ToLower(c)) + prevDash = false + } else if unicode.IsSpace(c) || c == '-' || c == '_' { + if !prevDash && sb.Len() > 0 { + sb.WriteRune('-') + prevDash = true + } + } + } + result := sb.String() + return strings.TrimRight(result, "-") +} +``` + +- [ ] **Step 2: Write a test for GeneratePageSlug** + +```go +// models/pages_test.go +package models_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "lmika.dev/lmika/weiro/models" +) + +func TestGeneratePageSlug(t *testing.T) { + tests := []struct { + title string + want string + }{ + {"About Me", "about-me"}, + {" Contact Us ", "contact-us"}, + {"Hello---World", "hello-world"}, + {"FAQ", "faq"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + assert.Equal(t, tt.want, models.GeneratePageSlug(tt.title)) + }) + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `go test ./models/ -run TestGeneratePageSlug -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add models/pages.go models/pages_test.go +git commit -m "feat(pages): add Page model and slug generator" +``` + +--- + +### Task 3: DB Provider for Pages + +**Files:** +- Create: `providers/db/pages.go` + +- [ ] **Step 1: Write the DB provider methods** + +Follow the pattern from `providers/db/categories.go`. The conversion function maps sqlgen types to model types. `ShowInNav` maps from `int64` (0/1) to `bool`. Timestamps map via `time.Unix(row.CreatedAt, 0).UTC()`. + +```go +// providers/db/pages.go +package db + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" +) + +func (db *Provider) SelectPagesOfSite(ctx context.Context, siteID int64) ([]*models.Page, error) { + rows, err := db.queries.SelectPagesOfSite(ctx, siteID) + if err != nil { + return nil, err + } + pages := make([]*models.Page, len(rows)) + for i, row := range rows { + pages[i] = dbPageToPage(row) + } + return pages, nil +} + +func (db *Provider) SelectPage(ctx context.Context, id int64) (*models.Page, error) { + row, err := db.queries.SelectPage(ctx, id) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SelectPageByGUID(ctx context.Context, guid string) (*models.Page, error) { + row, err := db.queries.SelectPageByGUID(ctx, guid) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SelectPageBySlugAndSite(ctx context.Context, siteID int64, slug string) (*models.Page, error) { + row, err := db.queries.SelectPageBySlugAndSite(ctx, sqlgen.SelectPageBySlugAndSiteParams{ + SiteID: siteID, + Slug: slug, + }) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SavePage(ctx context.Context, page *models.Page) error { + if page.ID == 0 { + showInNav := int64(0) + if page.ShowInNav { + showInNav = 1 + } + newID, err := db.queries.InsertPage(ctx, sqlgen.InsertPageParams{ + SiteID: page.SiteID, + Guid: page.GUID, + Title: page.Title, + Slug: page.Slug, + Body: page.Body, + PageType: int64(page.PageType), + ShowInNav: showInNav, + SortOrder: int64(page.SortOrder), + CreatedAt: timeToInt(page.CreatedAt), + UpdatedAt: timeToInt(page.UpdatedAt), + }) + if err != nil { + return err + } + page.ID = newID + return nil + } + + showInNav := int64(0) + if page.ShowInNav { + showInNav = 1 + } + return db.queries.UpdatePage(ctx, sqlgen.UpdatePageParams{ + Title: page.Title, + Slug: page.Slug, + Body: page.Body, + PageType: int64(page.PageType), + ShowInNav: showInNav, + UpdatedAt: timeToInt(page.UpdatedAt), + ID: page.ID, + }) +} + +func (db *Provider) UpdatePageSortOrder(ctx context.Context, id int64, sortOrder int) error { + return db.queries.UpdatePageSortOrder(ctx, sqlgen.UpdatePageSortOrderParams{ + SortOrder: int64(sortOrder), + ID: id, + }) +} + +func (db *Provider) DeletePage(ctx context.Context, id int64) error { + return db.queries.DeletePage(ctx, id) +} + +func dbPageToPage(row sqlgen.Page) *models.Page { + return &models.Page{ + ID: row.ID, + SiteID: row.SiteID, + GUID: row.Guid, + Title: row.Title, + Slug: row.Slug, + Body: row.Body, + PageType: int(row.PageType), + ShowInNav: row.ShowInNav != 0, + SortOrder: int(row.SortOrder), + CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), + UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(), + } +} +``` + +**Important:** The exact field names on `sqlgen.InsertPageParams`, `sqlgen.UpdatePageParams`, etc. depend on what sqlc generates. Check the generated code in `providers/db/gen/sqlgen/pages.sql.go` to confirm field names and types before writing this file. Adjust as needed. + +- [ ] **Step 2: Verify it compiles** + +Run: `go build ./providers/db/...` +Expected: Clean compile + +- [ ] **Step 3: Commit** + +```bash +git add providers/db/pages.go +git commit -m "feat(pages): add DB provider methods for pages" +``` + +--- + +### Task 4: Pages Service + +**Files:** +- Create: `services/pages/service.go` +- Modify: `services/services.go` + +- [ ] **Step 1: Write the pages service** + +Follow the pattern from `services/categories/service.go`. The service gets site from context, validates ownership, generates slugs, and queues republish on mutations. + +```go +// services/pages/service.go +package pages + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/publisher" +) + +type CreatePageParams struct { + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Slug string `form:"slug" json:"slug"` + Body string `form:"body" json:"body"` + PageType int `form:"page_type" json:"page_type"` + ShowInNav bool `form:"show_in_nav" json:"show_in_nav"` +} + +type Service struct { + db *db.Provider + publisher *publisher.Queue +} + +func New(db *db.Provider, publisher *publisher.Queue) *Service { + return &Service{db: db, publisher: publisher} +} + +func (s *Service) ListPages(ctx context.Context) ([]*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + return s.db.SelectPagesOfSite(ctx, site.ID) +} + +func (s *Service) GetPage(ctx context.Context, id int64) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + return page, nil +} + +func (s *Service) CreatePage(ctx context.Context, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + now := time.Now() + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + // Check slug collision + if _, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil { + return nil, models.SlugConflictError + } else if !db.ErrorIsNoRows(err) { + return nil, err + } + + // Determine sort order: place at end + existingPages, err := s.db.SelectPagesOfSite(ctx, site.ID) + if err != nil { + return nil, err + } + sortOrder := len(existingPages) + + page := &models.Page{ + SiteID: site.ID, + GUID: params.GUID, + Title: params.Title, + Slug: slug, + Body: params.Body, + PageType: params.PageType, + ShowInNav: params.ShowInNav, + SortOrder: sortOrder, + CreatedAt: now, + UpdatedAt: now, + } + if page.GUID == "" { + page.GUID = models.NewNanoID() + } + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) UpdatePage(ctx context.Context, id int64, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + // Check slug collision (exclude self) + if existing, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != page.ID { + return nil, models.SlugConflictError + } else if err != nil && !db.ErrorIsNoRows(err) { + return nil, err + } + + page.Title = params.Title + page.Slug = slug + page.Body = params.Body + page.PageType = params.PageType + page.ShowInNav = params.ShowInNav + page.UpdatedAt = time.Now() + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) DeletePage(ctx context.Context, id int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + + if err := s.db.DeletePage(ctx, id); err != nil { + return err + } + + s.publisher.Queue(site) + return nil +} + +func (s *Service) ReorderPages(ctx context.Context, pageIDs []int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + // Verify all pages belong to this site + for i, id := range pageIDs { + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + if err := s.db.UpdatePageSortOrder(ctx, id, i); err != nil { + return err + } + } + + s.publisher.Queue(site) + return nil +} +``` + +- [ ] **Step 2: Make SlugConflictError generic** + +In `models/errors.go`, change: +```go +var SlugConflictError = errors.New("a category with this slug already exists") +``` +To: +```go +var SlugConflictError = errors.New("a record with this slug already exists") +``` + +- [ ] **Step 3: Wire up the service in services/services.go** + +Add to the `Services` struct: +```go +Pages *pages.Service +``` + +Add to `New()`: +```go +pagesService := pages.New(dbp, publisherQueue) +``` + +And include in the return struct: +```go +Pages: pagesService, +``` + +Add import: `"lmika.dev/lmika/weiro/services/pages"` + +- [ ] **Step 4: Verify it compiles** + +Run: `go build ./services/...` +Expected: Clean compile + +- [ ] **Step 5: Commit** + +```bash +git add services/pages/ services/services.go models/errors.go +git commit -m "feat(pages): add pages service layer" +``` + +--- + +### Task 5: Pages Handler and Routes + +**Files:** +- Create: `handlers/pages.go` +- Modify: `cmds/server.go` + +- [ ] **Step 1: Write the pages handler** + +Follow the pattern from `handlers/categories.go` for CRUD, plus a `Reorder` handler that accepts JSON. + +```go +// handlers/pages.go +package handlers + +import ( + "fmt" + "strconv" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/pages" +) + +type PagesHandler struct { + PageService *pages.Service +} + +func (ph PagesHandler) Index(c fiber.Ctx) error { + pagesList, err := ph.PageService.ListPages(c.Context()) + if err != nil { + return err + } + + return c.Render("pages/index", fiber.Map{ + "pages": pagesList, + }) +} + +func (ph PagesHandler) New(c fiber.Ctx) error { + page := models.Page{ + GUID: models.NewNanoID(), + } + return c.Render("pages/edit", fiber.Map{ + "page": page, + "isNew": true, + "bodyClass": "page-edit-page", + }) +} + +func (ph PagesHandler) Edit(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + page, err := ph.PageService.GetPage(c.Context(), pageID) + if err != nil { + return err + } + + return c.Render("pages/edit", fiber.Map{ + "page": page, + "isNew": false, + "bodyClass": "page-edit-page", + }) +} + +func (ph PagesHandler) Create(c fiber.Ctx) error { + var req pages.CreatePageParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err := ph.PageService.CreatePage(c.Context(), req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Update(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + var req pages.CreatePageParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err = ph.PageService.UpdatePage(c.Context(), pageID, req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Delete(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + if err := ph.PageService.DeletePage(c.Context(), pageID); err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Reorder(c fiber.Ctx) error { + var req struct { + PageIDs []int64 `json:"page_ids"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + if err := ph.PageService.ReorderPages(c.Context(), req.PageIDs); err != nil { + return err + } + + return c.JSON(fiber.Map{"ok": true}) +} +``` + +- [ ] **Step 2: Register routes in cmds/server.go** + +After the categories route block (~line 150), add: + +```go +pgh := handlers.PagesHandler{PageService: svcs.Pages} +``` + +And routes on `siteGroup`: + +```go +siteGroup.Get("/pages", pgh.Index) +siteGroup.Get("/pages/new", pgh.New) +siteGroup.Get("/pages/:pageID", pgh.Edit) +siteGroup.Post("/pages", pgh.Create) +siteGroup.Post("/pages/reorder", pgh.Reorder) +siteGroup.Post("/pages/:pageID", pgh.Update) +siteGroup.Post("/pages/:pageID/delete", pgh.Delete) +``` + +Add import: `// already imported via handlers package` + +- [ ] **Step 3: Verify it compiles** + +Run: `go build ./...` +Expected: Clean compile + +- [ ] **Step 4: Commit** + +```bash +git add handlers/pages.go cmds/server.go +git commit -m "feat(pages): add pages handler and admin routes" +``` + +--- + +### Task 6: Admin Views - Page List with Drag-and-Drop + +**Files:** +- Create: `views/pages/index.html` +- Create: `assets/js/controllers/pagelist.js` +- Modify: `assets/js/main.js` +- Modify: `views/_common/nav.html` + +- [ ] **Step 1: Add "Pages" to the admin nav bar** + +In `views/_common/nav.html`, add a new `
| + | Title | +Slug | +Nav | +
|---|---|---|---|
| ☰ | +{{ .Title }} | +{{ .Slug }} |
+ {{ if .ShowInNav }}Yes{{ end }} | +
About this site
\n", +``` + +- [ ] **Step 6: Run the builder test** + +Run: `go test ./providers/sitebuilder/ -v` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add providers/sitebuilder/render_pages.go providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/pages_single.html providers/sitebuilder/builder_test.go +git commit -m "feat(pages): render pages in site builder after all other content" +``` + +--- + +### Task 10: Integration Test - Full Compile and Verify + +**Files:** None (verification only) + +- [ ] **Step 1: Run all tests** + +Run: `go test ./...` +Expected: All tests pass. + +- [ ] **Step 2: Verify clean build** + +Run: `go build ./...` +Expected: Clean compile, no errors. + +- [ ] **Step 3: Commit any fixes if needed** + +Only if previous steps required adjustments. 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. diff --git a/handlers/pages.go b/handlers/pages.go new file mode 100644 index 0000000..abefb41 --- /dev/null +++ b/handlers/pages.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "fmt" + "strconv" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/pages" +) + +type PagesHandler struct { + PageService *pages.Service +} + +func (ph PagesHandler) Index(c fiber.Ctx) error { + pagesList, err := ph.PageService.ListPages(c.Context()) + if err != nil { + return err + } + + return c.Render("pages/index", fiber.Map{ + "pages": pagesList, + }) +} + +func (ph PagesHandler) New(c fiber.Ctx) error { + page := models.Page{ + GUID: models.NewNanoID(), + } + return c.Render("pages/edit", fiber.Map{ + "page": page, + "isNew": true, + "bodyClass": "post-edit-page", + }) +} + +func (ph PagesHandler) Edit(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + page, err := ph.PageService.GetPage(c.Context(), pageID) + if err != nil { + return err + } + + return c.Render("pages/edit", fiber.Map{ + "page": page, + "isNew": false, + "bodyClass": "post-edit-page", + }) +} + +func (ph PagesHandler) Create(c fiber.Ctx) error { + var req pages.CreatePageParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err := ph.PageService.CreatePage(c.Context(), req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Update(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + var req pages.CreatePageParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err = ph.PageService.UpdatePage(c.Context(), pageID, req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Delete(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + if err := ph.PageService.DeletePage(c.Context(), pageID); err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Reorder(c fiber.Ctx) error { + var req struct { + PageIDs []int64 `json:"page_ids"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + if err := ph.PageService.ReorderPages(c.Context(), req.PageIDs); err != nil { + return err + } + + return c.JSON(fiber.Map{"ok": true}) +} diff --git a/layouts/simplecss/templates/categories_single.html b/layouts/simplecss/templates/categories_single.html index e9e7116..133ad8d 100644 --- a/layouts/simplecss/templates/categories_single.html +++ b/layouts/simplecss/templates/categories_single.html @@ -11,7 +11,7 @@ {{ end }} {{ if or .PrevURL .NextURL }} {{ end }} diff --git a/layouts/simplecss/templates/pages_single.html b/layouts/simplecss/templates/pages_single.html new file mode 100644 index 0000000..6883c3e --- /dev/null +++ b/layouts/simplecss/templates/pages_single.html @@ -0,0 +1,2 @@ +{{ if .Page.Title }}This is a test post
\n", "2026/02/20/another-post/index.html": "This is another test post
\n", "index.html": "Test Post,Another Post,", + "about/index.html": "About this site
\n", } outDir := t.TempDir() diff --git a/providers/sitebuilder/render_pages.go b/providers/sitebuilder/render_pages.go new file mode 100644 index 0000000..6183088 --- /dev/null +++ b/providers/sitebuilder/render_pages.go @@ -0,0 +1,31 @@ +package sitebuilder + +import ( + "bytes" + "context" + "html/template" + "io" +) + +func (b *Builder) renderPages(bctx buildContext) error { + for _, page := range b.site.Pages { + var md bytes.Buffer + if err := b.mdRenderer.RenderTo(context.Background(), &md, page.Body); err != nil { + return err + } + + data := pageSingleData{ + commonData: commonData{Site: b.site}, + Page: page, + HTML: template.HTML(md.String()), + } + + path := "/" + page.Slug + if err := b.createAtPath(bctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNamePageSingle, data) + }); err != nil { + return err + } + } + return nil +} diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index e0ece37..029cab0 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -26,12 +26,16 @@ const ( // tmplNameCategorySingle is the template for a single category page tmplNameCategorySingle = "categories_single.html" + + // tmplNamePageSingle is the template for a single page (pageSingleData) + tmplNamePageSingle = "pages_single.html" ) type Options struct { - BasePosts string // BasePosts is the base path for posts. - BaseUploads string // BaseUploads is the base path for uploads. - BaseStatic string // BaseStatic is the base path for static assets. + BasePosts string // BasePosts is the base path for posts. + BasePostList string // BasePostList is the base path for post lists. + BaseUploads string // BaseUploads is the base path for uploads. + BaseStatic string // BaseStatic is the base path for static assets. // TemplatesFS provides the raw templates for rendering the site. TemplatesFS fs.FS @@ -92,3 +96,9 @@ type categorySingleData struct { PrevURL string NextURL string } + +type pageSingleData struct { + commonData + Page *models.Page + HTML template.HTML +} diff --git a/services/pages/service.go b/services/pages/service.go new file mode 100644 index 0000000..8a82bc0 --- /dev/null +++ b/services/pages/service.go @@ -0,0 +1,198 @@ +package pages + +import ( + "context" + "strings" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/publisher" +) + +type CreatePageParams struct { + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Slug string `form:"slug" json:"slug"` + Body string `form:"body" json:"body"` + PageType int `form:"page_type" json:"page_type"` + ShowInNav bool `form:"show_in_nav" json:"show_in_nav"` +} + +type Service struct { + db *db.Provider + publisher *publisher.Queue +} + +func New(db *db.Provider, publisher *publisher.Queue) *Service { + return &Service{db: db, publisher: publisher} +} + +func (s *Service) ListPages(ctx context.Context) ([]*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + return s.db.SelectPagesOfSite(ctx, site.ID) +} + +func (s *Service) GetPage(ctx context.Context, id int64) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + return page, nil +} + +func (s *Service) CreatePage(ctx context.Context, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + now := time.Now() + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + + // Check slug collision + if _, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil { + return nil, models.SlugConflictError + } else if !db.ErrorIsNoRows(err) { + return nil, err + } + + // Determine sort order: place at end + existingPages, err := s.db.SelectPagesOfSite(ctx, site.ID) + if err != nil { + return nil, err + } + sortOrder := len(existingPages) + + page := &models.Page{ + SiteID: site.ID, + GUID: params.GUID, + Title: params.Title, + Slug: slug, + Body: params.Body, + PageType: params.PageType, + ShowInNav: params.ShowInNav, + SortOrder: sortOrder, + CreatedAt: now, + UpdatedAt: now, + } + if page.GUID == "" { + page.GUID = models.NewNanoID() + } + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) UpdatePage(ctx context.Context, id int64, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + + // Check slug collision (exclude self) + if existing, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != page.ID { + return nil, models.SlugConflictError + } else if err != nil && !db.ErrorIsNoRows(err) { + return nil, err + } + + page.Title = params.Title + page.Slug = slug + page.Body = params.Body + page.PageType = params.PageType + page.ShowInNav = params.ShowInNav + page.UpdatedAt = time.Now() + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) DeletePage(ctx context.Context, id int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + + if err := s.db.DeletePage(ctx, id); err != nil { + return err + } + + s.publisher.Queue(site) + return nil +} + +func (s *Service) ReorderPages(ctx context.Context, pageIDs []int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + // Verify all pages belong to this site + for i, id := range pageIDs { + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + if err := s.db.UpdatePageSortOrder(ctx, id, i); err != nil { + return err + } + } + + s.publisher.Queue(site) + return nil +} diff --git a/services/publisher/service.go b/services/publisher/service.go index 939817a..adfcdd7 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -65,6 +65,12 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { }) } + // Fetch pages + sitePages, err := p.db.SelectPagesOfSite(ctx, site.ID) + if err != nil { + return err + } + for _, target := range targets { if !target.Enabled { continue @@ -84,6 +90,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { CategoriesOfPost: func(ctx context.Context, postID int64) ([]*models.Category, error) { return p.db.SelectCategoriesOfPost(ctx, postID) }, + Pages: sitePages, OpenUpload: func(u models.Upload) (io.ReadCloser, error) { return p.up.OpenUpload(site, u) }, @@ -114,13 +121,14 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ } sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ - BasePosts: "/posts", - BaseUploads: "/uploads", - BaseStatic: "/static", - TemplatesFS: templateFS, - StaticFS: staticFS, - FeedItems: 30, - RenderTZ: renderTZ, + BasePosts: "/posts", + BasePostList: "/pages", + BaseUploads: "/uploads", + BaseStatic: "/static", + TemplatesFS: templateFS, + StaticFS: staticFS, + FeedItems: 30, + RenderTZ: renderTZ, }) if err != nil { return err diff --git a/services/services.go b/services/services.go index beb6727..852dea3 100644 --- a/services/services.go +++ b/services/services.go @@ -8,6 +8,7 @@ import ( "lmika.dev/lmika/weiro/providers/uploadfiles" "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/categories" + "lmika.dev/lmika/weiro/services/pages" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" "lmika.dev/lmika/weiro/services/sites" @@ -23,6 +24,7 @@ type Services struct { Sites *sites.Service Uploads *uploads.Service Categories *categories.Service + Pages *pages.Service } func New(cfg config.Config) (*Services, error) { @@ -40,6 +42,7 @@ func New(cfg config.Config) (*Services, error) { siteService := sites.New(dbp) uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending")) categoriesService := categories.New(dbp, publisherQueue) + pagesService := pages.New(dbp, publisherQueue) return &Services{ DB: dbp, @@ -50,6 +53,7 @@ func New(cfg config.Config) (*Services, error) { Sites: siteService, Uploads: uploadService, Categories: categoriesService, + Pages: pagesService, }, nil } diff --git a/sql/queries/pages.sql b/sql/queries/pages.sql new file mode 100644 index 0000000..0df22ff --- /dev/null +++ b/sql/queries/pages.sql @@ -0,0 +1,34 @@ +-- name: SelectPagesOfSite :many +SELECT * FROM pages +WHERE site_id = ? ORDER BY sort_order ASC; + +-- name: SelectPage :one +SELECT * FROM pages WHERE id = ? LIMIT 1; + +-- name: SelectPageByGUID :one +SELECT * FROM pages WHERE guid = ? LIMIT 1; + +-- name: SelectPageBySlugAndSite :one +SELECT * FROM pages WHERE site_id = ? AND slug = ? LIMIT 1; + +-- name: InsertPage :one +INSERT INTO pages ( + site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id; + +-- name: UpdatePage :exec +UPDATE pages SET + title = ?, + slug = ?, + body = ?, + page_type = ?, + show_in_nav = ?, + updated_at = ? +WHERE id = ?; + +-- name: UpdatePageSortOrder :exec +UPDATE pages SET sort_order = ? WHERE id = ?; + +-- name: DeletePage :exec +DELETE FROM pages WHERE id = ?; diff --git a/sql/schema/06_pages.up.sql b/sql/schema/06_pages.up.sql new file mode 100644 index 0000000..5090456 --- /dev/null +++ b/sql/schema/06_pages.up.sql @@ -0,0 +1,17 @@ +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); diff --git a/views/_common/nav.html b/views/_common/nav.html index e8bce30..e9c0de7 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -11,11 +11,14 @@ Posts| + | Title | +Slug | +Nav | +
|---|---|---|---|
| ☰ | +{{ .Title }} | +{{ .Slug }} |
+ {{ if .ShowInNav }}Yes{{ end }} | +