Merge pull request 'Pages' (#5) from feature/pages into main
Reviewed-on: #5
This commit is contained in:
commit
cc0da8d668
63
assets/js/controllers/pagelist.js
Normal file
63
assets/js/controllers/pagelist.js
Normal file
|
|
@ -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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import LogoutController from "./controllers/logout";
|
||||||
import FirstRunController from "./controllers/firstrun";
|
import FirstRunController from "./controllers/firstrun";
|
||||||
import UploadController from "./controllers/upload";
|
import UploadController from "./controllers/upload";
|
||||||
import ShowUploadController from "./controllers/show_upload";
|
import ShowUploadController from "./controllers/show_upload";
|
||||||
|
import PagelistController from "./controllers/pagelist";
|
||||||
|
|
||||||
window.Stimulus = Application.start()
|
window.Stimulus = Application.start()
|
||||||
Stimulus.register("toast", ToastController);
|
Stimulus.register("toast", ToastController);
|
||||||
|
|
@ -16,3 +17,4 @@ Stimulus.register("logout", LogoutController);
|
||||||
Stimulus.register("first-run", FirstRunController);
|
Stimulus.register("first-run", FirstRunController);
|
||||||
Stimulus.register("upload", UploadController);
|
Stimulus.register("upload", UploadController);
|
||||||
Stimulus.register("show-upload", ShowUploadController);
|
Stimulus.register("show-upload", ShowUploadController);
|
||||||
|
Stimulus.register("pagelist", PagelistController);
|
||||||
|
|
@ -113,6 +113,7 @@ Starting weiro without any arguments will start the server.
|
||||||
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
|
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
|
||||||
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
|
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
|
||||||
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
|
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
|
||||||
|
pgh := handlers.PagesHandler{PageService: svcs.Pages}
|
||||||
|
|
||||||
app.Get("/login", lh.Login)
|
app.Get("/login", lh.Login)
|
||||||
app.Post("/login", lh.DoLogin)
|
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", ch.Update)
|
||||||
siteGroup.Post("/categories/:categoryID/delete", ch.Delete)
|
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("/", middleware.OptionalUser(svcs.Auth), ih.Index)
|
||||||
app.Get("/first-run", ih.FirstRun)
|
app.Get("/first-run", ih.FirstRun)
|
||||||
app.Post("/first-run", ih.FirstRunSubmit)
|
app.Post("/first-run", ih.FirstRunSubmit)
|
||||||
|
|
|
||||||
1218
docs/superpowers/plans/2026-03-22-pages.md
Normal file
1218
docs/superpowers/plans/2026-03-22-pages.md
Normal file
File diff suppressed because it is too large
Load diff
148
docs/superpowers/specs/2026-03-22-pages-design.md
Normal file
148
docs/superpowers/specs/2026-03-22-pages-design.md
Normal file
|
|
@ -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.
|
||||||
118
handlers/pages.go
Normal file
118
handlers/pages.go
Normal file
|
|
@ -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})
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if or .PrevURL .NextURL }}
|
{{ if or .PrevURL .NextURL }}
|
||||||
<nav class="pagination">
|
<nav class="pagination">
|
||||||
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
|
{{ if .PrevURL }}<a href="{{ url_abs .PrevURL }}">← Newer posts</a>{{ end }}
|
||||||
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
|
{{ if .NextURL }}<a href="{{ url_abs .NextURL }}">Older posts →</a>{{ end }}
|
||||||
</nav>
|
</nav>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
2
layouts/simplecss/templates/pages_single.html
Normal file
2
layouts/simplecss/templates/pages_single.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
{{ if .Page.Title }}<h2>{{ .Page.Title }}</h2>{{ end }}
|
||||||
|
{{ .HTML }}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if or .PrevURL .NextURL }}
|
{{ if or .PrevURL .NextURL }}
|
||||||
<nav class="pagination">
|
<nav class="pagination">
|
||||||
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
|
{{ if .PrevURL }}<a href="{{ url_abs .PrevURL }}">← Newer posts</a>{{ end }}
|
||||||
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
|
{{ if .NextURL }}<a href="{{ url_abs .NextURL }}">Older posts →</a>{{ end }}
|
||||||
</nav>
|
</nav>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@ var PermissionError = errors.New("permission denied")
|
||||||
var NotFoundError = errors.New("not found")
|
var NotFoundError = errors.New("not found")
|
||||||
var SiteRequiredError = errors.New("site required")
|
var SiteRequiredError = errors.New("site required")
|
||||||
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
|
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
|
||||||
var SlugConflictError = errors.New("a category with this slug already exists")
|
var SlugConflictError = errors.New("a record with this slug already exists")
|
||||||
|
|
|
||||||
45
models/pages.go
Normal file
45
models/pages.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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, "-")
|
||||||
|
}
|
||||||
26
models/pages_test.go
Normal file
26
models/pages_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,4 +18,5 @@ type Site struct {
|
||||||
Categories []models.CategoryWithCount
|
Categories []models.CategoryWithCount
|
||||||
PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]
|
PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]
|
||||||
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
|
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
|
||||||
|
Pages []*models.Page
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,20 @@ type Category struct {
|
||||||
UpdatedAt int64
|
UpdatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Page struct {
|
||||||
|
ID int64
|
||||||
|
SiteID int64
|
||||||
|
Guid string
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
Body string
|
||||||
|
PageType int64
|
||||||
|
ShowInNav int64
|
||||||
|
SortOrder int64
|
||||||
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
type PendingUpload struct {
|
type PendingUpload struct {
|
||||||
ID int64
|
ID int64
|
||||||
SiteID int64
|
SiteID int64
|
||||||
|
|
|
||||||
219
providers/db/gen/sqlgen/pages.sql.go
Normal file
219
providers/db/gen/sqlgen/pages.sql.go
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.28.0
|
||||||
|
// source: pages.sql
|
||||||
|
|
||||||
|
package sqlgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deletePage = `-- name: DeletePage :exec
|
||||||
|
DELETE FROM pages WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeletePage(ctx context.Context, id int64) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deletePage, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertPage = `-- 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
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertPageParams struct {
|
||||||
|
SiteID int64
|
||||||
|
Guid string
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
Body string
|
||||||
|
PageType int64
|
||||||
|
ShowInNav int64
|
||||||
|
SortOrder int64
|
||||||
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertPage(ctx context.Context, arg InsertPageParams) (int64, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, insertPage,
|
||||||
|
arg.SiteID,
|
||||||
|
arg.Guid,
|
||||||
|
arg.Title,
|
||||||
|
arg.Slug,
|
||||||
|
arg.Body,
|
||||||
|
arg.PageType,
|
||||||
|
arg.ShowInNav,
|
||||||
|
arg.SortOrder,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
var id int64
|
||||||
|
err := row.Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPage = `-- name: SelectPage :one
|
||||||
|
SELECT id, site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at FROM pages WHERE id = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectPage(ctx context.Context, id int64) (Page, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, selectPage, id)
|
||||||
|
var i Page
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Title,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Body,
|
||||||
|
&i.PageType,
|
||||||
|
&i.ShowInNav,
|
||||||
|
&i.SortOrder,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPageByGUID = `-- name: SelectPageByGUID :one
|
||||||
|
SELECT id, site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at FROM pages WHERE guid = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectPageByGUID(ctx context.Context, guid string) (Page, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, selectPageByGUID, guid)
|
||||||
|
var i Page
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Title,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Body,
|
||||||
|
&i.PageType,
|
||||||
|
&i.ShowInNav,
|
||||||
|
&i.SortOrder,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPageBySlugAndSite = `-- name: SelectPageBySlugAndSite :one
|
||||||
|
SELECT id, site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at FROM pages WHERE site_id = ? AND slug = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type SelectPageBySlugAndSiteParams struct {
|
||||||
|
SiteID int64
|
||||||
|
Slug string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SelectPageBySlugAndSite(ctx context.Context, arg SelectPageBySlugAndSiteParams) (Page, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, selectPageBySlugAndSite, arg.SiteID, arg.Slug)
|
||||||
|
var i Page
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Title,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Body,
|
||||||
|
&i.PageType,
|
||||||
|
&i.ShowInNav,
|
||||||
|
&i.SortOrder,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectPagesOfSite = `-- name: SelectPagesOfSite :many
|
||||||
|
SELECT id, site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at FROM pages
|
||||||
|
WHERE site_id = ? ORDER BY sort_order ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectPagesOfSite(ctx context.Context, siteID int64) ([]Page, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, selectPagesOfSite, siteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Page
|
||||||
|
for rows.Next() {
|
||||||
|
var i Page
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Title,
|
||||||
|
&i.Slug,
|
||||||
|
&i.Body,
|
||||||
|
&i.PageType,
|
||||||
|
&i.ShowInNav,
|
||||||
|
&i.SortOrder,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePage = `-- name: UpdatePage :exec
|
||||||
|
UPDATE pages SET
|
||||||
|
title = ?,
|
||||||
|
slug = ?,
|
||||||
|
body = ?,
|
||||||
|
page_type = ?,
|
||||||
|
show_in_nav = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdatePageParams struct {
|
||||||
|
Title string
|
||||||
|
Slug string
|
||||||
|
Body string
|
||||||
|
PageType int64
|
||||||
|
ShowInNav int64
|
||||||
|
UpdatedAt int64
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdatePage(ctx context.Context, arg UpdatePageParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updatePage,
|
||||||
|
arg.Title,
|
||||||
|
arg.Slug,
|
||||||
|
arg.Body,
|
||||||
|
arg.PageType,
|
||||||
|
arg.ShowInNav,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePageSortOrder = `-- name: UpdatePageSortOrder :exec
|
||||||
|
UPDATE pages SET sort_order = ? WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdatePageSortOrderParams struct {
|
||||||
|
SortOrder int64
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdatePageSortOrder(ctx context.Context, arg UpdatePageSortOrderParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, updatePageSortOrder, arg.SortOrder, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
115
providers/db/pages.go
Normal file
115
providers/db/pages.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -118,7 +118,12 @@ func (b *Builder) BuildSite(outDir string) error {
|
||||||
// Build static assets
|
// Build static assets
|
||||||
eg.Go(func() error { return b.writeStaticAssets(buildCtx) })
|
eg.Go(func() error { return b.writeStaticAssets(buildCtx) })
|
||||||
|
|
||||||
return eg.Wait()
|
if err := eg.Wait(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render pages last so they can override auto-generated content
|
||||||
|
return b.renderPages(buildCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
|
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
|
||||||
|
|
@ -161,14 +166,10 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co
|
||||||
|
|
||||||
var prevURL, nextURL string
|
var prevURL, nextURL string
|
||||||
if page > 1 {
|
if page > 1 {
|
||||||
if page == 2 {
|
prevURL = fmt.Sprintf("%v/%d", b.opts.BasePostList, page-1)
|
||||||
prevURL = "/posts/"
|
|
||||||
} else {
|
|
||||||
prevURL = fmt.Sprintf("/posts/%d/", page-1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if page < totalPages {
|
if page < totalPages {
|
||||||
nextURL = fmt.Sprintf("/posts/%d/", page+1)
|
nextURL = fmt.Sprintf("%v/%d", b.opts.BasePostList, page+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
pl := postListData{
|
pl := postListData{
|
||||||
|
|
@ -182,9 +183,9 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co
|
||||||
// Page 1 renders at both root and /posts/
|
// Page 1 renders at both root and /posts/
|
||||||
var paths []string
|
var paths []string
|
||||||
if page == 1 {
|
if page == 1 {
|
||||||
paths = []string{"", "/posts"}
|
paths = []string{"", fmt.Sprintf("%v/1", b.opts.BasePostList)}
|
||||||
} else {
|
} else {
|
||||||
paths = []string{fmt.Sprintf("/posts/%d", page)}
|
paths = []string{fmt.Sprintf("%v/%d", b.opts.BasePostList, page)}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ func TestBuilder_BuildSite(t *testing.T) {
|
||||||
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
|
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
|
||||||
"categories_list.html": {Data: []byte(`{{ range .Categories}}<a href="{{url_abs .Path}}">{{.Name}}</a>,{{ end }}`)},
|
"categories_list.html": {Data: []byte(`{{ range .Categories}}<a href="{{url_abs .Path}}">{{.Name}}</a>,{{ end }}`)},
|
||||||
"categories_single.html": {Data: []byte(`<h2>{{.Category.Name}}</h2>`)},
|
"categories_single.html": {Data: []byte(`<h2>{{.Category.Name}}</h2>`)},
|
||||||
|
"pages_single.html": {Data: []byte(`{{ if .Page.Title }}<h2>{{ .Page.Title }}</h2>{{ end }}{{ .HTML }}`)},
|
||||||
}
|
}
|
||||||
|
|
||||||
posts := []*models.Post{
|
posts := []*models.Post{
|
||||||
|
|
@ -49,11 +50,15 @@ func TestBuilder_BuildSite(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Pages: []*models.Page{
|
||||||
|
{Title: "About", Slug: "about", Body: "About this site"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
wantFiles := map[string]string{
|
wantFiles := map[string]string{
|
||||||
"2026/02/18/test-post/index.html": "<p>This is a test post</p>\n",
|
"2026/02/18/test-post/index.html": "<p>This is a test post</p>\n",
|
||||||
"2026/02/20/another-post/index.html": "<p>This is <strong>another</strong> test post</p>\n",
|
"2026/02/20/another-post/index.html": "<p>This is <strong>another</strong> test post</p>\n",
|
||||||
"index.html": "<a href=\"https://example.com/2026/02/18/test-post\">Test Post</a>,<a href=\"https://example.com/2026/02/20/another-post\">Another Post</a>,",
|
"index.html": "<a href=\"https://example.com/2026/02/18/test-post\">Test Post</a>,<a href=\"https://example.com/2026/02/20/another-post\">Another Post</a>,",
|
||||||
|
"about/index.html": "<h2>About</h2><p>About this site</p>\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
outDir := t.TempDir()
|
outDir := t.TempDir()
|
||||||
|
|
|
||||||
31
providers/sitebuilder/render_pages.go
Normal file
31
providers/sitebuilder/render_pages.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -26,12 +26,16 @@ const (
|
||||||
|
|
||||||
// tmplNameCategorySingle is the template for a single category page
|
// tmplNameCategorySingle is the template for a single category page
|
||||||
tmplNameCategorySingle = "categories_single.html"
|
tmplNameCategorySingle = "categories_single.html"
|
||||||
|
|
||||||
|
// tmplNamePageSingle is the template for a single page (pageSingleData)
|
||||||
|
tmplNamePageSingle = "pages_single.html"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
BasePosts string // BasePosts is the base path for posts.
|
BasePosts string // BasePosts is the base path for posts.
|
||||||
BaseUploads string // BaseUploads is the base path for uploads.
|
BasePostList string // BasePostList is the base path for post lists.
|
||||||
BaseStatic string // BaseStatic is the base path for static assets.
|
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 provides the raw templates for rendering the site.
|
||||||
TemplatesFS fs.FS
|
TemplatesFS fs.FS
|
||||||
|
|
@ -92,3 +96,9 @@ type categorySingleData struct {
|
||||||
PrevURL string
|
PrevURL string
|
||||||
NextURL string
|
NextURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pageSingleData struct {
|
||||||
|
commonData
|
||||||
|
Page *models.Page
|
||||||
|
HTML template.HTML
|
||||||
|
}
|
||||||
|
|
|
||||||
198
services/pages/service.go
Normal file
198
services/pages/service.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
for _, target := range targets {
|
||||||
if !target.Enabled {
|
if !target.Enabled {
|
||||||
continue
|
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) {
|
CategoriesOfPost: func(ctx context.Context, postID int64) ([]*models.Category, error) {
|
||||||
return p.db.SelectCategoriesOfPost(ctx, postID)
|
return p.db.SelectCategoriesOfPost(ctx, postID)
|
||||||
},
|
},
|
||||||
|
Pages: sitePages,
|
||||||
OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
|
OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
|
||||||
return p.up.OpenUpload(site, u)
|
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{
|
sb, err := sitebuilder.New(pubSite, sitebuilder.Options{
|
||||||
BasePosts: "/posts",
|
BasePosts: "/posts",
|
||||||
BaseUploads: "/uploads",
|
BasePostList: "/pages",
|
||||||
BaseStatic: "/static",
|
BaseUploads: "/uploads",
|
||||||
TemplatesFS: templateFS,
|
BaseStatic: "/static",
|
||||||
StaticFS: staticFS,
|
TemplatesFS: templateFS,
|
||||||
FeedItems: 30,
|
StaticFS: staticFS,
|
||||||
RenderTZ: renderTZ,
|
FeedItems: 30,
|
||||||
|
RenderTZ: renderTZ,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"lmika.dev/lmika/weiro/providers/uploadfiles"
|
"lmika.dev/lmika/weiro/providers/uploadfiles"
|
||||||
"lmika.dev/lmika/weiro/services/auth"
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
"lmika.dev/lmika/weiro/services/categories"
|
"lmika.dev/lmika/weiro/services/categories"
|
||||||
|
"lmika.dev/lmika/weiro/services/pages"
|
||||||
"lmika.dev/lmika/weiro/services/posts"
|
"lmika.dev/lmika/weiro/services/posts"
|
||||||
"lmika.dev/lmika/weiro/services/publisher"
|
"lmika.dev/lmika/weiro/services/publisher"
|
||||||
"lmika.dev/lmika/weiro/services/sites"
|
"lmika.dev/lmika/weiro/services/sites"
|
||||||
|
|
@ -23,6 +24,7 @@ type Services struct {
|
||||||
Sites *sites.Service
|
Sites *sites.Service
|
||||||
Uploads *uploads.Service
|
Uploads *uploads.Service
|
||||||
Categories *categories.Service
|
Categories *categories.Service
|
||||||
|
Pages *pages.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config) (*Services, error) {
|
func New(cfg config.Config) (*Services, error) {
|
||||||
|
|
@ -40,6 +42,7 @@ func New(cfg config.Config) (*Services, error) {
|
||||||
siteService := sites.New(dbp)
|
siteService := sites.New(dbp)
|
||||||
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
|
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
|
||||||
categoriesService := categories.New(dbp, publisherQueue)
|
categoriesService := categories.New(dbp, publisherQueue)
|
||||||
|
pagesService := pages.New(dbp, publisherQueue)
|
||||||
|
|
||||||
return &Services{
|
return &Services{
|
||||||
DB: dbp,
|
DB: dbp,
|
||||||
|
|
@ -50,6 +53,7 @@ func New(cfg config.Config) (*Services, error) {
|
||||||
Sites: siteService,
|
Sites: siteService,
|
||||||
Uploads: uploadService,
|
Uploads: uploadService,
|
||||||
Categories: categoriesService,
|
Categories: categoriesService,
|
||||||
|
Pages: pagesService,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
34
sql/queries/pages.sql
Normal file
34
sql/queries/pages.sql
Normal file
|
|
@ -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 = ?;
|
||||||
17
sql/schema/06_pages.up.sql
Normal file
17
sql/schema/06_pages.up.sql
Normal file
|
|
@ -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);
|
||||||
|
|
@ -11,11 +11,14 @@
|
||||||
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/categories">Categories</a>
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/pages">Pages</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/uploads">Uploads</a>
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/uploads">Uploads</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/categories">Categories</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/settings">Settings</a>
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/settings">Settings</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
55
views/pages/edit.html
Normal file
55
views/pages/edit.html
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<main class="flex-grow-1 position-relative">
|
||||||
|
{{ if .isNew }}
|
||||||
|
<form method="post" class="container-fluid post-form py-2" action="/sites/{{ .site.ID }}/pages">
|
||||||
|
{{ else }}
|
||||||
|
<form method="post" class="container-fluid post-form py-2" action="/sites/{{ .site.ID }}/pages/{{ .page.ID }}">
|
||||||
|
{{ end }}
|
||||||
|
<input type="hidden" name="guid" value="{{ .page.GUID }}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-9">
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .page.Title }}">
|
||||||
|
</div>
|
||||||
|
<textarea name="body" class="form-control flex-grow-1" rows="20">{{ .page.Body }}</textarea>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="submit" class="btn btn-primary">{{ if .isNew }}Create{{ else }}Save{{ end }}</button>
|
||||||
|
{{ if not .isNew }}
|
||||||
|
<button type="button" class="btn btn-outline-danger ms-2"
|
||||||
|
onclick="if(confirm('Delete this page?')) { document.getElementById('delete-form').submit(); }">Delete</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Navigation</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="pageSlug" class="form-label">Slug</label>
|
||||||
|
<input type="text" class="form-control" id="pageSlug" name="slug" value="{{ .page.Slug }}" placeholder="Leave blank to generate">
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="show_in_nav" value="true" id="showInNav"
|
||||||
|
{{ if .page.ShowInNav }}checked{{ end }}>
|
||||||
|
<label class="form-check-label" for="showInNav">Show in Nav</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Page Settings</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="pageType" class="form-label">Page Type</label>
|
||||||
|
<select class="form-select" id="pageType" name="page_type">
|
||||||
|
<option value="0" {{ if eq .page.PageType 0 }}selected{{ end }}>Normal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ if not .isNew }}
|
||||||
|
<form id="delete-form" method="post" action="/sites/{{ .site.ID }}/pages/{{ .page.ID }}/delete" style="display:none;"></form>
|
||||||
|
{{ end }}
|
||||||
|
</main>
|
||||||
35
views/pages/index.html
Normal file
35
views/pages/index.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<main class="container">
|
||||||
|
<div class="my-4 d-flex justify-content-between align-items-baseline">
|
||||||
|
<div>
|
||||||
|
<a href="/sites/{{ .site.ID }}/pages/new" class="btn btn-success">New Page</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .pages }}
|
||||||
|
<table class="table" data-controller="pagelist" data-pagelist-site-id-value="{{ .site.ID }}">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 2rem;"></th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Nav</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody data-pagelist-target="list">
|
||||||
|
{{ range .pages }}
|
||||||
|
<tr draggable="true" data-page-id="{{ .ID }}"
|
||||||
|
data-action="dragstart->pagelist#dragStart dragover->pagelist#dragOver drop->pagelist#drop dragend->pagelist#dragEnd">
|
||||||
|
<td class="text-muted" style="cursor: grab;">☰</td>
|
||||||
|
<td><a href="/sites/{{ $.site.ID }}/pages/{{ .ID }}">{{ .Title }}</a></td>
|
||||||
|
<td><code>{{ .Slug }}</code></td>
|
||||||
|
<td>{{ if .ShowInNav }}Yes{{ end }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ else }}
|
||||||
|
<div class="h4 m-3 text-center">
|
||||||
|
<div class="position-absolute top-50 start-50 translate-middle">No pages yet.</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</main>
|
||||||
|
|
@ -11,12 +11,12 @@
|
||||||
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
|
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
|
||||||
</div>
|
</div>
|
||||||
<textarea data-postedit-target="bodyTextEdit" name="body" class="form-control flex-grow-1" rows="3">{{.post.Body}}</textarea>
|
<textarea data-postedit-target="bodyTextEdit" name="body" class="form-control flex-grow-1" rows="3">{{.post.Body}}</textarea>
|
||||||
<div>
|
<div class="mt-2">
|
||||||
{{ if $isPublished }}
|
{{ if $isPublished }}
|
||||||
<input type="submit" name="action" class="btn btn-primary mt-2" value="Update">
|
<input type="submit" name="action" class="btn btn-primary" value="Update">
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<input type="submit" name="action" class="btn btn-primary mt-2" value="Publish">
|
<input type="submit" name="action" class="btn btn-primary" value="Publish">
|
||||||
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
|
<input type="submit" name="action" class="btn btn-secondary" value="Save Draft">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue