Compare commits

..

43 commits
v0.1.1 ... main

Author SHA1 Message Date
Leon Mika d21aeadd56 Fixed build 2026-03-29 20:29:42 +11:00
Leon Mika deca23b599 Fixed ordering of published posts 2026-03-29 12:26:05 +11:00
Leon Mika 9b20665d11 Added support for footnotes and fixed category AJAX post 2026-03-29 10:45:45 +11:00
Leon Mika 023574aac6 Removed the login challenge 2026-03-29 09:33:24 +11:00
lmika 5ed00c97d6 Merge pull request 'A simple way to edit images' (#7) from feature/image-edit into main
Reviewed-on: #7
2026-03-28 10:47:31 +00:00
Leon Mika 98828a4849 Removed some unused code 2026-03-28 21:45:54 +11:00
Leon Mika c8a276b248 Have got saving working 2026-03-28 21:42:35 +11:00
Leon Mika f9a65c8ca9 Have got adjusting processor arguments working 2026-03-27 21:43:03 +11:00
Leon Mika 488942db2e Started working on proper parameters 2026-03-26 22:14:57 +11:00
Leon Mika 2d42a0ef90 Have got removing parameters working 2026-03-26 21:44:20 +11:00
Leon Mika 599c72d465 Have got the processor plumbing working 2026-03-26 21:16:50 +11:00
Leon Mika 036b683eab Have got session creation working 2026-03-25 22:35:53 +11:00
Leon Mika 18f9f49c0a Started UI for editing images 2026-03-25 21:09:57 +11:00
lmika 8c371ccae9 Merge pull request 'Added a site picker' (#6) from feature/site-picker into main
Reviewed-on: #6
2026-03-24 09:24:56 +00:00
Leon Mika d5bfdcbb06 Added support for nav items 2026-03-24 20:23:07 +11:00
Leon Mika d80aacc180 Added a site picker plus options to create new sites 2026-03-24 11:08:51 +11:00
lmika cc0da8d668 Merge pull request 'Pages' (#5) from feature/pages into main
Reviewed-on: #5
2026-03-23 10:54:17 +00:00
Leon Mika 5badce0d16 Updated some settings in pages 2026-03-23 21:48:43 +11:00
Leon Mika ef038172ac feat(pages): render pages in site builder after all other content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:11:12 +11:00
Leon Mika d464821a8c feat(pages): populate pages in publisher for site generation 2026-03-22 19:09:01 +11:00
Leon Mika 255fa26a15 feat(pages): add admin page edit form with sidebar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:07:49 +11:00
Leon Mika 5eece96700 feat(pages): add admin page list with drag-and-drop reorder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 19:06:48 +11:00
Leon Mika f386403ced feat(pages): add pages handler and admin routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:03:06 +11:00
Leon Mika 1edcd7686c feat(pages): add pages service layer
Implements the pages service with ListPages, GetPage, CreatePage,
UpdatePage, DeletePage, and ReorderPages methods. Wires the service
into the service registry and generalises SlugConflictError message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 18:01:36 +11:00
Leon Mika 2cd9ff8721 feat(pages): add DB provider methods for pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:59:43 +11:00
Leon Mika 7755bf5043 feat(pages): add Page model and slug generator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:58:37 +11:00
Leon Mika f17597e4b8 feat(pages): add pages table schema and sqlc queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 17:57:49 +11:00
Leon Mika 620ab6c6fa docs: add pages feature implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:47:19 +11:00
Leon Mika a00567a756 Add arbitrary pages feature design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:37:47 +11:00
lmika 3d8c6f5345 Merge pull request 'Paging' (#4) from feature/pages-and-paging into main
Reviewed-on: #4
2026-03-22 05:23:53 +00:00
Leon Mika 0a1631a7e0 Fixed paging URL 2026-03-22 16:22:32 +11:00
Leon Mika 40da63368a fix: add nil guard for StaticFS and set default PostsPerPage in FirstRun
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:41:50 +11:00
Leon Mika f68bac809f feat: add pagination to generated site category pages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:38:39 +11:00
Leon Mika 30884372d6 feat: add pagination to generated site post list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:37:42 +11:00
Leon Mika 550ebf728a feat: add posts per page setting to site settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:35:56 +11:00
Leon Mika d7a5d425b8 feat: add pagination controls to admin post list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:34:55 +11:00
Leon Mika 82feccf64a feat: add pagination to admin post list handler and service
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:33:31 +11:00
Leon Mika 113789a972 feat: add PageInfo model for pagination
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:32:02 +11:00
Leon Mika 5bf77ede5c feat: add CountPostsOfSite query and DB method
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:31:36 +11:00
Leon Mika 9919f3444a feat: add PostsPerPage to Site model and DB provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:28:50 +11:00
Leon Mika 9b36a35c1a feat: add posts_per_page column to sites table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:26:09 +11:00
Leon Mika 7c4dc0885e Add paging implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 13:12:28 +11:00
Leon Mika 4d96ec8b95 Add paging feature design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 12:46:43 +11:00
84 changed files with 5397 additions and 179 deletions

View file

@ -10,6 +10,15 @@ $container-max-widths: (
@import "bootstrap/scss/bootstrap.scss";
// Navbar
.navbar-site-visit {
display: inline-block;
line-height: 2em;
margin-bottom: 4px;
margin-right: 10px;
}
// Post list
.postlist .post img {
@ -22,19 +31,24 @@ $container-max-widths: (
font-size: 0.9rem;
}
// Post form
// Large editor
//
// Used for edit canvases which take up the entire window
// Post edit page styling
.post-edit-page {
.large-editor {
height: 100vh;
}
.post-edit-page main {
.large-editor main {
display: flex;
flex-direction: column;
overflow: hidden;
}
// Post form
// Post edit page styling
.post-edit-page .post-form {
flex: 1;
display: flex;

View file

@ -0,0 +1,233 @@
import feather from "feather-icons/dist/feather.js";
import Handlebars from "handlebars";
import {Controller} from "@hotwired/stimulus";
Handlebars.registerHelper("submit_on", function (id, event) {
return `data-action="${event}->edit-upload#updateProcessor" data-edit-upload-id-param="${id}"`
});
const processorFrame = Handlebars.compile(`
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span>{{name}}</span>
<a href="#" class="float-end"
data-action="edit-upload#removeProcessor"
data-edit-upload-id-param="{{id}}"
><i data-feather="x" width="18" height="18"></i></a>
</div>
<div class="card-body">
<form data-role="processor-params" data-params-id="{{id}}">{{{props}}}</form>
</div>
</div>
`);
const processorUIs = {
"shadow": {
label: "Shadow",
template: Handlebars.compile(`
<div class="row mb-3 align-items-center">
<label for="{{id}}_color" class="col-sm col-form-label">Colour</label>
<div class="col-sm">
<input name="color" class="form-control" id="{{id}}_color" type="color" value="{{props.color}}" {{{submit_on id 'change'}}}>
</div>
</div>
<div class="row mb-3">
<label for="{{id}}_offset_y" class="col-sm col-form-label">Offset Y</label>
<div class="col-sm">
<input name="offset_y" class="form-control" id="{{id}}_{{props.color}}" type="number" value="{{props.offset_y}}" {{{submit_on id 'blur'}}}>
</div>
</div>
`),
},
"resize": {
label: "Resize",
template: Handlebars.compile(`
<div class="mb-3">
<label for="{{id}}_width" class="form-label">Width</label>
<input name="width" class="form-control" id="{{id}}_width">
</div>
<div class="mb-3">
<label for="{{id}}_height" class="form-label">Height</label>
<input name="width" class="form-control" id="{{id}}_width">
</div>
`),
},
};
export default class UploadEditController extends Controller {
static targets = ['processList', 'preview'];
static values = {
uploadId: Number,
siteId: Number,
};
connect() {
this._rebuildProcessList();
this._createSession();
}
async addProcessor(ev) {
ev.preventDefault();
await this._addProcessor({
type: "shadow"
});
}
async removeProcessor(ev) {
ev.preventDefault();
let id = ev.params.id;
await this._removeProcessor(id);
}
async saveUpload(ev) {
ev.preventDefault();
await this._save("replace");
}
async saveNewUpload(ev) {
ev.preventDefault();
await this._save("copy");
}
async updateProcessor(ev) {
ev.preventDefault();
let id = ev.params.id;
let paramParentEl = ev.target.closest('[data-role="processor-params"]');
let params = Object.fromEntries(new FormData(paramParentEl).entries());
await this._updateProcessor(id, params);
}
_rebuildProcessList() {
let el = this.processListTarget;
if ((!this._state) || (!this._state.session) || (!this._state.session.processors)) {
return;
}
el.innerHTML = "";
for (let p of this._state.session.processors) {
let ui = processorUIs[p.type];
if (!ui) {
continue;
}
let cardOuter = processorFrame({
id: p.id,
name: ui.label,
props: ui.template(p),
});
el.innerHTML += cardOuter;
}
feather.replace();
}
async _createSession() {
try {
let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
"base_upload": this.uploadIdValue,
})
});
this._state = await resp.json();
this._rebuildProcessList();
this.previewTarget.src = this._state.preview_url;
} catch (e) {
console.error(e);
}
}
async _addProcessor(processor) {
try {
let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(processor)
});
this._state = await resp.json();
this._rebuildProcessList();
this.previewTarget.src = this._state.preview_url;
} catch (e) {
console.error(e);
}
}
async _updateProcessor(processorID, params) {
await this._doReturningState(async () => {
return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}`, {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
processor: {
id: processorID,
props: params,
}
})
})).json();
})
}
async _removeProcessor(processorID) {
await this._doReturningState(async () => {
return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors/${processorID}`, {
method: 'DELETE',
})).json();
})
}
async _save(mode) {
if (!this._state || !this._state.session) {
return;
}
try {
let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/save`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ mode })
});
if (!resp.ok) {
console.error("Save failed:", resp.statusText);
return;
}
let result = await resp.json();
window.location.href = `/sites/${this.siteIdValue}/uploads/${result.upload_id}`;
} catch (e) {
console.error(e);
}
}
async _doReturningState(fn) {
try {
this._state = await fn();
this._rebuildProcessList();
this.previewTarget.src = this._state.preview_url;
} catch (e) {
console.error(e);
}
}
}

View 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.",
});
}
}
}

View file

@ -60,6 +60,16 @@ export default class PosteditController extends Controller {
try {
const formData = new FormData(this.element);
let data = Object.fromEntries(formData.entries());
// Special handling for categories
let categoryIDs = [];
for (let i of formData.entries()) {
if (i[0] === "category_ids") {
categoryIDs.push(parseInt(i[1]))
}
}
data["category_ids"] = categoryIDs;
data = {...data, action: action || 'save'};
const response = await fetch(this.element.getAttribute("action"), {

View file

@ -1,3 +1,4 @@
import feather from "feather-icons/dist/feather.js";
import { Application } from "@hotwired/stimulus";
import ToastController from "./controllers/toast";
@ -7,6 +8,8 @@ import LogoutController from "./controllers/logout";
import FirstRunController from "./controllers/firstrun";
import UploadController from "./controllers/upload";
import ShowUploadController from "./controllers/show_upload";
import EditUploadController from "./controllers/edit_upload";
import PagelistController from "./controllers/pagelist";
window.Stimulus = Application.start()
Stimulus.register("toast", ToastController);
@ -16,3 +19,7 @@ Stimulus.register("logout", LogoutController);
Stimulus.register("first-run", FirstRunController);
Stimulus.register("upload", UploadController);
Stimulus.register("show-upload", ShowUploadController);
Stimulus.register("edit-upload", EditUploadController);
Stimulus.register("pagelist", PagelistController);
feather.replace();

View file

@ -111,14 +111,26 @@ Starting weiro without any arguments will start the server.
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories}
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
ieh := handlers.ImageEditHandlers{ImageEditService: svcs.ImageEdit}
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)
app.Post("/logout", lh.Logout)
siteGroup := app.Group("/sites/:siteID", middleware.LogErrors(), middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites))
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)
app.Get("/static/*", static.New("./static"))
app.Use(middleware.LogErrors(), middleware.RequireUser(svcs.Auth))
app.Get("/sites/new", ssh.New)
app.Post("/sites", ssh.Create)
siteGroup := app.Group("/sites/:siteID", middleware.RequiresSite(svcs.Sites))
siteGroup.Get("/posts", ph.Index)
siteGroup.Get("/posts/new", ph.New)
@ -138,6 +150,14 @@ Starting weiro without any arguments will start the server.
siteGroup.Post("/uploads/pending/:guid", uh.UploadPart)
siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete)
siteGroup.Delete("/uploads/:uploadID", uh.Delete)
siteGroup.Get("/uploads/:uploadID/edit", uh.Edit)
siteGroup.Post("/imageedit", ieh.Create)
siteGroup.Patch("/imageedit/:sessionID", ieh.PatchSession)
siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor)
siteGroup.Delete("/imageedit/:sessionID/processors/:processorID", ieh.DeleteProcessor)
siteGroup.Post("/imageedit/:sessionID/save", ieh.Save)
siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview)
siteGroup.Get("/settings", ssh.General)
siteGroup.Post("/settings", ssh.UpdateGeneral)
@ -149,11 +169,13 @@ Starting weiro without any arguments will start the server.
siteGroup.Post("/categories/:categoryID", ch.Update)
siteGroup.Post("/categories/:categoryID/delete", ch.Delete)
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)
app.Get("/static/*", static.New("./static"))
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)
if err := app.Listen(":3000"); err != nil {
log.Println(err)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,888 @@
# Paging Feature 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:** Add offset-based pagination to the admin post list and the generated static site (posts and category listings).
**Architecture:** Add a `posts_per_page` column to the `sites` table for configurable page size on the generated site. Admin uses a hardcoded page size of 25. The existing `db.PagingParams` and `LIMIT/OFFSET` SQL infrastructure is reused. A shared `models.PageInfo` type carries pagination state to templates.
**Tech Stack:** Go, SQLite, sqlc, Fiber v3, html/template, Bootstrap
---
### Task 1: Add `posts_per_page` column and regenerate sqlc
**Files:**
- Create: `sql/schema/05_posts_per_page.up.sql`
- Modify: `sql/queries/sites.sql:10-19` (InsertSite query)
- Modify: `sql/queries/sites.sql:24-25` (UpdateSite query)
- Regenerate: `providers/db/gen/sqlgen/` (sqlc output)
- [ ] **Step 1: Create migration file**
Create `sql/schema/05_posts_per_page.up.sql`:
```sql
ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10;
```
- [ ] **Step 2: Update the InsertSite SQL query**
In `sql/queries/sites.sql`, update the InsertSite query (lines 10-19) to include `posts_per_page`:
```sql
-- name: InsertSite :one
INSERT INTO sites (
owner_id,
guid,
title,
tagline,
timezone,
posts_per_page,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id;
```
- [ ] **Step 3: Update the UpdateSite SQL query**
In `sql/queries/sites.sql`, update line 24-25:
```sql
-- name: UpdateSite :exec
UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?;
```
- [ ] **Step 4: Regenerate sqlc**
Run: `sqlc generate`
Expected: `providers/db/gen/sqlgen/` files updated with new `PostsPerPage` field on `Site` struct, updated `InsertSiteParams` and `UpdateSiteParams`.
- [ ] **Step 5: Run tests to verify nothing broke**
Run: `go test ./...`
Expected: All existing tests pass.
- [ ] **Step 6: Commit**
```bash
git add sql/schema/05_posts_per_page.up.sql sql/queries/sites.sql providers/db/gen/sqlgen/
git commit -m "feat: add posts_per_page column to sites table"
```
---
### Task 2: Update Site model and DB provider for `PostsPerPage`
**Files:**
- Modify: `models/sites.go:24-33` (Site struct)
- Modify: `providers/db/sites.go:42-65` (SaveSite)
- Modify: `providers/db/sites.go:102-112` (dbSiteToSite)
- [ ] **Step 1: Add `PostsPerPage` to `models.Site`**
In `models/sites.go`, add to the `Site` struct (after `Timezone`):
```go
PostsPerPage int
```
- [ ] **Step 2: Update `dbSiteToSite` in `providers/db/sites.go`**
In `providers/db/sites.go`, update `dbSiteToSite` (line 102) to map the new field:
```go
func dbSiteToSite(row sqlgen.Site) models.Site {
return models.Site{
ID: row.ID,
OwnerID: row.OwnerID,
GUID: row.Guid,
Title: row.Title,
Timezone: row.Timezone,
Tagline: row.Tagline,
PostsPerPage: int(row.PostsPerPage),
Created: time.Unix(row.CreatedAt, 0).UTC(),
}
}
```
- [ ] **Step 3: Update `SaveSite` to include `PostsPerPage`**
In `providers/db/sites.go`, update the `InsertSite` call (line 44) to include `PostsPerPage`:
```go
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
OwnerID: site.OwnerID,
Guid: site.GUID,
Title: site.Title,
Tagline: site.Tagline,
Timezone: site.Timezone,
PostsPerPage: int64(site.PostsPerPage),
CreatedAt: timeToInt(site.Created),
})
```
Update the `UpdateSite` call (line 59) to include `PostsPerPage`:
```go
return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{
Title: site.Title,
Tagline: site.Tagline,
Timezone: site.Timezone,
PostsPerPage: int64(site.PostsPerPage),
ID: site.ID,
})
```
- [ ] **Step 4: Run tests**
Run: `go test ./...`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add models/sites.go providers/db/sites.go sql/queries/sites.sql providers/db/gen/sqlgen/
git commit -m "feat: add PostsPerPage to Site model and DB provider"
```
---
### Task 3: Add `CountPostsOfSite` SQL query and DB method
**Files:**
- Modify: `sql/queries/posts.sql` (add count query)
- Modify: `providers/db/posts.go` (add CountPostsOfSite method)
- Modify: `providers/db/provider_test.go` (add test)
- Regenerate: `providers/db/gen/sqlgen/`
- [ ] **Step 1: Write the failing test**
Add to `providers/db/provider_test.go` inside `TestProvider_Posts`:
```go
t.Run("count posts of site", func(t *testing.T) {
countSite := &models.Site{
OwnerID: user.ID,
GUID: models.NewNanoID(),
Title: "Count Blog",
}
require.NoError(t, p.SaveSite(ctx, countSite))
now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC)
for i := 0; i < 3; i++ {
post := &models.Post{
SiteID: countSite.ID,
GUID: models.NewNanoID(),
Title: fmt.Sprintf("Post %d", i),
Body: "body",
Slug: fmt.Sprintf("/post-%d", i),
CreatedAt: now,
}
require.NoError(t, p.SavePost(ctx, post))
}
count, err := p.CountPostsOfSite(ctx, countSite.ID, false)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
// Soft-delete one post
posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0})
require.NoError(t, err)
require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID))
count, err = p.CountPostsOfSite(ctx, countSite.ID, false)
require.NoError(t, err)
assert.Equal(t, int64(2), count)
count, err = p.CountPostsOfSite(ctx, countSite.ID, true)
require.NoError(t, err)
assert.Equal(t, int64(1), count)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v`
Expected: FAIL — `CountPostsOfSite` method does not exist.
- [ ] **Step 3: Add SQL query**
Add to `sql/queries/posts.sql`:
```sql
-- name: CountPostsOfSite :one
SELECT COUNT(*) FROM posts
WHERE site_id = sqlc.arg(site_id) AND (
CASE CAST (sqlc.arg(post_filter) AS TEXT)
WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0
END
);
```
Run: `sqlc generate`
- [ ] **Step 4: Add DB provider method**
Add to `providers/db/posts.go`:
```go
func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) {
filter := "active"
if showDeleted {
filter = "deleted"
}
return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{
SiteID: siteID,
PostFilter: filter,
})
}
```
Note: check the generated `sqlgen.CountPostsOfSiteParams` struct name and fields after `sqlc generate` — adjust if the field names differ.
- [ ] **Step 5: Run test to verify it passes**
Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v`
Expected: PASS
- [ ] **Step 6: Run all tests**
Run: `go test ./...`
Expected: All pass.
- [ ] **Step 7: Commit**
```bash
git add sql/queries/posts.sql providers/db/posts.go providers/db/provider_test.go providers/db/gen/sqlgen/
git commit -m "feat: add CountPostsOfSite query and DB method"
```
---
### Task 4: Add `models.PageInfo` type
**Files:**
- Create: `models/paging.go`
- [ ] **Step 1: Create `models/paging.go`**
```go
package models
// PageInfo carries pagination state for templates.
type PageInfo struct {
CurrentPage int
TotalPages int
PostsPerPage int
}
// HasPrevious returns true if there is a previous page.
func (p PageInfo) HasPrevious() bool {
return p.CurrentPage > 1
}
// HasNext returns true if there is a next page.
func (p PageInfo) HasNext() bool {
return p.CurrentPage < p.TotalPages
}
// PreviousPage returns the previous page number.
func (p PageInfo) PreviousPage() int {
return p.CurrentPage - 1
}
// NextPage returns the next page number.
func (p PageInfo) NextPage() int {
return p.CurrentPage + 1
}
```
- [ ] **Step 2: Run tests**
Run: `go test ./...`
Expected: All pass (no tests yet for this type, but it should compile).
- [ ] **Step 3: Commit**
```bash
git add models/paging.go
git commit -m "feat: add PageInfo model for pagination"
```
---
### Task 5: Add pagination to admin post list (service + handler)
**Files:**
- Modify: `services/posts/list.go:15-38` (ListPosts signature and implementation)
- Modify: `handlers/posts.go:18-39` (Index handler)
- [ ] **Step 1: Update `ListPosts` to accept paging params and return count**
Replace `services/posts/list.go` `ListPosts` method:
```go
type ListPostsResult struct {
Posts []*PostWithCategories
TotalCount int64
}
func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) {
site, ok := models.GetSite(ctx)
if !ok {
return ListPostsResult{}, models.SiteRequiredError
}
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging)
if err != nil {
return ListPostsResult{}, err
}
count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted)
if err != nil {
return ListPostsResult{}, err
}
result := make([]*PostWithCategories, len(posts))
for i, post := range posts {
cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID)
if err != nil {
return ListPostsResult{}, err
}
result[i] = &PostWithCategories{Post: post, Categories: cats}
}
return ListPostsResult{Posts: result, TotalCount: count}, nil
}
```
- [ ] **Step 2: Update the admin handler**
Replace `handlers/posts.go` `Index` method:
```go
func (ph PostsHandler) Index(c fiber.Ctx) error {
var req struct {
Filter string `query:"filter"`
Page int `query:"page"`
}
if err := c.Bind().Query(&req); err != nil {
return fiber.ErrBadRequest
}
const perPage = 25
if req.Page < 1 {
req.Page = 1
}
result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{
Offset: int64((req.Page - 1) * perPage),
Limit: perPage,
})
if err != nil {
return err
}
totalPages := int(result.TotalCount+int64(perPage)-1) / perPage
if totalPages < 1 {
totalPages = 1
}
pageInfo := models.PageInfo{
CurrentPage: req.Page,
TotalPages: totalPages,
PostsPerPage: perPage,
}
return accepts(c, json(func() any {
return result.Posts
}), html(func(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{
"req": req,
"posts": result.Posts,
"pageInfo": pageInfo,
})
}))
}
```
Note: add `"lmika.dev/lmika/weiro/providers/db"` and `"lmika.dev/lmika/weiro/models"` to imports in `handlers/posts.go`.
- [ ] **Step 3: Verify it compiles**
Run: `go build ./...`
Expected: Compiles successfully.
- [ ] **Step 4: Run tests**
Run: `go test ./...`
Expected: All pass.
- [ ] **Step 5: Commit**
```bash
git add services/posts/list.go handlers/posts.go
git commit -m "feat: add pagination to admin post list handler and service"
```
---
### Task 6: Add pagination UI to admin post list template
**Files:**
- Modify: `views/posts/index.html`
- [ ] **Step 1: Add pagination controls to admin template**
Add pagination controls after the post list in `views/posts/index.html`. Insert before the closing `</main>` tag:
```html
{{ if gt .pageInfo.TotalPages 1 }}
<nav aria-label="Page navigation" class="my-4">
<ul class="pagination justify-content-center">
<li class="page-item{{ if not .pageInfo.HasPrevious }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.PreviousPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Previous</a>
</li>
{{ range $p := .pageInfo.Pages }}
<li class="page-item{{ if eq $p $.pageInfo.CurrentPage }} active{{ end }}">
<a class="page-link" href="?page={{ $p }}{{ if $.req.Filter }}&filter={{ $.req.Filter }}{{ end }}">{{ $p }}</a>
</li>
{{ end }}
<li class="page-item{{ if not .pageInfo.HasNext }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.NextPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Next</a>
</li>
</ul>
</nav>
{{ end }}
```
- [ ] **Step 2: Add `Pages` method to `PageInfo`**
Add to `models/paging.go`:
```go
// Pages returns a slice of page numbers for rendering numbered pagination.
func (p PageInfo) Pages() []int {
pages := make([]int, p.TotalPages)
for i := range pages {
pages[i] = i + 1
}
return pages
}
```
- [ ] **Step 3: Verify it compiles and test manually**
Run: `go build ./...`
Expected: Compiles.
- [ ] **Step 4: Commit**
```bash
git add views/posts/index.html models/paging.go
git commit -m "feat: add pagination controls to admin post list"
```
---
### Task 7: Add site settings form for `PostsPerPage`
**Files:**
- Modify: `views/sitesettings/general.html:17-48` (form)
- Modify: `services/sites/services.go:131-158` (UpdateSiteSettingsParams and UpdateSiteSettings)
- [ ] **Step 1: Add `PostsPerPage` to `UpdateSiteSettingsParams`**
In `services/sites/services.go`, update the struct (line 131):
```go
type UpdateSiteSettingsParams struct {
SiteID int64 `form:"siteID"`
Name string `form:"name"`
Tagline string `form:"tagline"`
Timezone string `form:"timezone"`
PostsPerPage int `form:"postsPerPage"`
}
```
- [ ] **Step 2: Update `UpdateSiteSettings` to handle `PostsPerPage`**
In `services/sites/services.go`, update `UpdateSiteSettings` (line 138) to validate and set the new field:
```go
func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) {
site, err := s.GetSiteByID(ctx, params.SiteID)
if err != nil {
return models.Site{}, err
}
_, err = time.LoadLocation(params.Timezone)
if err != nil {
return models.Site{}, errors.Wrap(err, "invalid timezone")
}
postsPerPage := params.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 1
} else if postsPerPage > 100 {
postsPerPage = 100
}
site.Title = params.Name
site.Tagline = params.Tagline
site.Timezone = params.Timezone
site.PostsPerPage = postsPerPage
if err := s.db.SaveSite(ctx, &site); err != nil {
return models.Site{}, err
}
return site, nil
}
```
- [ ] **Step 3: Add form field to settings template**
In `views/sitesettings/general.html`, add after the Timezone field (after line 43, before the submit button row):
```html
<div class="row mb-3">
<label for="postsPerPage" class="col-sm-3 col-form-label text-end">Posts Per Page</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="postsPerPage" name="postsPerPage" value="{{ .site.PostsPerPage }}" min="1" max="100">
<div class="form-text">Number of posts per page on the generated site.</div>
</div>
</div>
```
- [ ] **Step 4: Verify it compiles**
Run: `go build ./...`
Expected: Compiles.
- [ ] **Step 5: Commit**
```bash
git add services/sites/services.go views/sitesettings/general.html
git commit -m "feat: add posts per page setting to site settings"
```
---
### Task 8: Add pagination to generated site post list
**Files:**
- Modify: `providers/sitebuilder/tmpls.go:62-65` (postListData)
- Modify: `providers/sitebuilder/builder.go:124-146` (renderPostListWithCategories)
- Modify: `layouts/simplecss/templates/posts_list.html`
- [ ] **Step 1: Update `postListData` to include `PageInfo`**
In `providers/sitebuilder/tmpls.go`, update `postListData` (line 62):
```go
type postListData struct {
commonData
Posts []postSingleData
PageInfo models.PageInfo
PrevURL string
NextURL string
}
```
- [ ] **Step 2: Rewrite `renderPostListWithCategories` to paginate**
Replace `renderPostListWithCategories` in `providers/sitebuilder/builder.go` (line 124):
```go
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
// Collect all posts
var allPosts []postSingleData
for mp := range b.site.PostIter(ctx) {
post, err := mp.Get()
if err != nil {
return err
}
rp, err := b.renderPostWithCategories(ctx, post)
if err != nil {
return err
}
allPosts = append(allPosts, rp)
}
postsPerPage := b.site.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 10
}
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
if totalPages < 1 {
totalPages = 1
}
for page := 1; page <= totalPages; page++ {
start := (page - 1) * postsPerPage
end := start + postsPerPage
if end > len(allPosts) {
end = len(allPosts)
}
pageInfo := models.PageInfo{
CurrentPage: page,
TotalPages: totalPages,
PostsPerPage: postsPerPage,
}
var prevURL, nextURL string
if page > 1 {
if page == 2 {
prevURL = "/posts/"
} else {
prevURL = fmt.Sprintf("/posts/page/%d/", page-1)
}
}
if page < totalPages {
nextURL = fmt.Sprintf("/posts/page/%d/", page+1)
}
pl := postListData{
commonData: commonData{Site: b.site},
Posts: allPosts[start:end],
PageInfo: pageInfo,
PrevURL: prevURL,
NextURL: nextURL,
}
// Determine output path(s) for this page
var paths []string
if page == 1 {
// Page 1 renders at both root and /posts/
paths = []string{"", "/posts"}
} else {
paths = []string{fmt.Sprintf("/posts/page/%d", page)}
}
for _, path := range paths {
if err := b.createAtPath(bctx, path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostList, pl)
}); err != nil {
return err
}
}
}
return nil
}
```
- [ ] **Step 3: Update the post list template with prev/next links**
Replace `layouts/simplecss/templates/posts_list.html`:
```html
{{ range .Posts }}
<div class="h-entry">
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
{{ template "_post_meta.html" . }}
</div>
{{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}
```
- [ ] **Step 4: Run tests**
Run: `go test ./...`
Expected: Existing builder test may need updating (see next step).
- [ ] **Step 5: Update builder test**
The test in `providers/sitebuilder/builder_test.go` creates a `pubmodel.Site` without `PostsPerPage`, which will default to 0. Update the test site to set `PostsPerPage`:
```go
site := pubmodel.Site{
Site: models.Site{PostsPerPage: 10},
BaseURL: "https://example.com",
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
// ... existing code ...
},
}
```
The expected `index.html` content stays the same since both posts fit on one page.
- [ ] **Step 6: Run tests**
Run: `go test ./...`
Expected: All pass.
- [ ] **Step 7: Commit**
```bash
git add providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/posts_list.html providers/sitebuilder/builder_test.go
git commit -m "feat: add pagination to generated site post list"
```
---
### Task 9: Add pagination to generated site category pages
**Files:**
- Modify: `providers/sitebuilder/tmpls.go:82-88` (categorySingleData)
- Modify: `providers/sitebuilder/builder.go:315-362` (renderCategoryPages)
- Modify: `layouts/simplecss/templates/categories_single.html`
- [ ] **Step 1: Update `categorySingleData` to include pagination**
In `providers/sitebuilder/tmpls.go`, update `categorySingleData` (line 82):
```go
type categorySingleData struct {
commonData
Category *models.Category
DescriptionHTML template.HTML
Posts []postSingleData
Path string
PageInfo models.PageInfo
PrevURL string
NextURL string
}
```
- [ ] **Step 2: Rewrite `renderCategoryPages` to paginate**
Replace `renderCategoryPages` in `providers/sitebuilder/builder.go` (line 315):
```go
func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) error {
for _, cwc := range b.site.Categories {
if cwc.PostCount == 0 {
continue
}
// Collect all posts for this category
var allPosts []postSingleData
for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) {
post, err := mp.Get()
if err != nil {
return err
}
rp, err := b.renderPostWithCategories(goCtx, post)
if err != nil {
return err
}
allPosts = append(allPosts, rp)
}
var descHTML bytes.Buffer
if cwc.Description != "" {
if err := b.mdRenderer.RenderTo(goCtx, &descHTML, cwc.Description); err != nil {
return err
}
}
postsPerPage := b.site.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 10
}
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
if totalPages < 1 {
totalPages = 1
}
basePath := fmt.Sprintf("/categories/%s", cwc.Slug)
for page := 1; page <= totalPages; page++ {
start := (page - 1) * postsPerPage
end := start + postsPerPage
if end > len(allPosts) {
end = len(allPosts)
}
pageInfo := models.PageInfo{
CurrentPage: page,
TotalPages: totalPages,
PostsPerPage: postsPerPage,
}
var prevURL, nextURL string
if page > 1 {
if page == 2 {
prevURL = basePath + "/"
} else {
prevURL = fmt.Sprintf("%s/page/%d/", basePath, page-1)
}
}
if page < totalPages {
nextURL = fmt.Sprintf("%s/page/%d/", basePath, page+1)
}
path := basePath
if page > 1 {
path = fmt.Sprintf("%s/page/%d", basePath, page)
}
data := categorySingleData{
commonData: commonData{Site: b.site},
Category: &cwc.Category,
DescriptionHTML: template.HTML(descHTML.String()),
Posts: allPosts[start:end],
Path: path,
PageInfo: pageInfo,
PrevURL: prevURL,
NextURL: nextURL,
}
if err := b.createAtPath(ctx, path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNameCategorySingle, data)
}); err != nil {
return err
}
}
// Per-category feeds (use all posts, not paginated)
if err := b.renderCategoryFeed(ctx, cwc, allPosts); err != nil {
return err
}
}
return nil
}
```
- [ ] **Step 3: Update category single template with prev/next links**
Replace `layouts/simplecss/templates/categories_single.html`:
```html
{{ if .DescriptionHTML }}<div class="category-description">{{ .DescriptionHTML }}</div>{{ end }}
{{ range .Posts }}
<div class="h-entry">
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
{{ template "_post_meta.html" . }}
</div>
{{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}
```
Note: check the current content of `categories_single.html` first — preserve any existing structure (like `<h2>` headings) that may not have been captured in the exploration. Read the file before editing.
- [ ] **Step 4: Run tests**
Run: `go test ./...`
Expected: All pass.
- [ ] **Step 5: Commit**
```bash
git add providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/categories_single.html
git commit -m "feat: add pagination to generated site category pages"
```
---
### Task 10: Final verification
- [ ] **Step 1: Run full test suite**
Run: `go test ./...`
Expected: All tests pass.
- [ ] **Step 2: Build the project**
Run: `go build ./...`
Expected: Clean build with no errors.
- [ ] **Step 3: Commit any remaining changes**
If any files were missed, stage and commit them.

View 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.

View file

@ -0,0 +1,100 @@
# Paging Feature Design
## Overview
Introduce offset-based pagination to the admin post list and the generated static site (both post listings and category listings).
## Data Layer
### New `sites` column
Add `posts_per_page INTEGER NOT NULL DEFAULT 10` to the `sites` table. This setting controls the number of posts per page on the **generated static site only**.
### New SQL queries
- `CountPostsOfSite(siteID, showDeleted)` — returns total post count for the site
- `CountPostsOfCategory(categoryID)` — returns total published post count for a category
### Model changes
**`models.Site`** — add field:
```go
PostsPerPage int
```
**New shared type** (`models/paging.go`):
```go
type PageInfo struct {
CurrentPage int
TotalPages int
PostsPerPage int
}
```
Existing `db.PagingParams` and queries (`SelectPostsOfSite`, `SelectPostsOfCategory`) already support `LIMIT/OFFSET` and remain unchanged.
## Admin Section
### Post list pagination
- **Page size: hardcoded at 25** (not tied to the `PostsPerPage` site setting)
- Handler (`handlers/posts.go` `Index()`) reads a `page` query parameter (default 1)
- Computes offset as `(page - 1) * 25`
- Fetches total post count via new `CountPosts()` service method to build `PageInfo`
- Passes `PageInfo` to template
### Service changes
- `ListPosts()` accepts paging params from the handler instead of hardcoding them
- New `CountPosts()` method that calls the count query
### Template (`views/posts/index.html`)
- Full numbered pagination with Previous/Next below the post list: `< 1 2 3 ... 10 >`
- Preserves existing query params (e.g. `?filter=deleted`) when paginating
- Both regular post list and trash view are paginated
### Site settings form
- Add "Posts per page" number input to `views/sitesettings/general.html`
- Add `PostsPerPage` field to `UpdateSiteSettingsParams`
- Server-side validation: minimum 1, maximum 100
## Generated Static Site
### URL structure
Post listing pages:
- `/posts/` — page 1
- `/posts/page/2/` — page 2
- `/posts/page/N/` — page N
Category listing pages:
- `/categories/<slug>/` — page 1
- `/categories/<slug>/page/2/` — page 2
- `/categories/<slug>/page/N/` — page N
### Site root
`/` (site root) shows the same content as `/posts/` (page 1 of all posts).
### Builder changes (`providers/sitebuilder/builder.go`)
- Instead of rendering one `posts_list.html` with all posts, generate multiple page files
- Uses `site.PostsPerPage` from the site setting to determine page size
- Same pattern for category pages
### Publisher changes (`services/publisher/iter.go`)
- Existing iterator fetches posts in batches of 50 internally — this stays as-is
- The builder chunks posts into pages of `PostsPerPage` size and renders each page as a separate HTML file
### Template (`layouts/simplecss/templates/posts_list.html`)
- Receives `PageInfo` plus the posts for that page
- Renders **Previous / Next** links only (no numbered pagination)
- Previous link hidden on page 1; Next link hidden on last page
## Approach
Offset-based pagination using the existing `db.PagingParams` infrastructure. Page number maps to offset: `offset = (page - 1) * postsPerPage`.

165
handlers/imageedit.go Normal file
View file

@ -0,0 +1,165 @@
package handlers
import (
"bufio"
"io"
"log"
"net/http"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/imgedit"
)
type ImageEditHandlers struct {
ImageEditService *imgedit.Service
}
type sessionResponse struct {
Session *models.ImageEditSession `json:"session"`
PreviewURL string `json:"preview_url"`
}
func (ieh ImageEditHandlers) Create(c fiber.Ctx) error {
var req struct {
BaseUploadID int64 `json:"base_upload"`
}
if err := c.Bind().JSON(&req); err != nil {
return err
}
res, err := ieh.ImageEditService.NewSession(c.Context(), req.BaseUploadID)
if err != nil {
return err
}
var resp = sessionResponse{
Session: res,
PreviewURL: res.PreviewURL(),
}
return c.Status(http.StatusCreated).JSON(resp)
}
func (ieh ImageEditHandlers) Preview(c fiber.Ctx) error {
log.Printf("Previewing image edit session %v/%v", c.Params("sessionID"), c.Params("versionID"))
sessionID := c.Params("sessionID")
versionID := c.Params("versionID")
mimeTime, rw, err := ieh.ImageEditService.LoadImageVersion(c.Context(), sessionID, versionID)
if err != nil {
return err
}
c.Set("Content-Type", mimeTime)
c.Status(http.StatusOK)
return c.SendStreamWriter(func(w *bufio.Writer) {
rw, err := rw()
if err != nil {
return
}
defer rw.Close()
_, err = io.Copy(w, rw)
if err != nil {
return
}
})
}
func (ieh ImageEditHandlers) AddProcessor(c fiber.Ctx) error {
sessionID := c.Params("sessionID")
if sessionID == "" {
log.Println("No session ID")
return fiber.ErrBadRequest
}
var req imgedit.AddProcessorReq
if err := c.Bind().Body(&req); err != nil {
log.Printf("Failed to parse request body: %v", err)
return fiber.ErrBadRequest
}
res, err := ieh.ImageEditService.AddProcessor(c.Context(), sessionID, req)
if err != nil {
return err
}
return c.Status(http.StatusOK).JSON(sessionResponse{
Session: res,
PreviewURL: res.PreviewURL(),
})
}
func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error {
sessionID := c.Params("sessionID")
if sessionID == "" {
return fiber.ErrBadRequest
}
processorID := c.Params("processorID")
if processorID == "" {
return fiber.ErrBadRequest
}
res, err := ieh.ImageEditService.DeleteProcessor(c.Context(), sessionID, processorID)
if err != nil {
return err
}
return c.Status(http.StatusOK).JSON(sessionResponse{
Session: res,
PreviewURL: res.PreviewURL(),
})
}
func (ieh ImageEditHandlers) Save(c fiber.Ctx) error {
sessionID := c.Params("sessionID")
if sessionID == "" {
return fiber.ErrBadRequest
}
var req struct {
Mode string `json:"mode"`
}
if err := c.Bind().JSON(&req); err != nil {
return fiber.ErrBadRequest
}
result, err := ieh.ImageEditService.Save(c.Context(), sessionID, req.Mode)
if err != nil {
return err
}
return c.Status(http.StatusOK).JSON(result)
}
func (ieh ImageEditHandlers) PatchSession(c fiber.Ctx) error {
var req struct {
UpdateProc *imgedit.UpdateProcessorReq `json:"processor"`
}
sessionID := c.Params("sessionID")
if sessionID == "" {
return fiber.ErrBadRequest
}
if err := c.Bind().Body(&req); err != nil {
return err
}
log.Printf("Got request: %v", *req.UpdateProc)
if req.UpdateProc != nil {
res, err := ieh.ImageEditService.UpdateProcessor(c.Context(), sessionID, *req.UpdateProc)
if err != nil {
return err
}
return c.Status(http.StatusOK).JSON(sessionResponse{
Session: res,
PreviewURL: res.PreviewURL(),
})
}
return fiber.ErrBadRequest
}

View file

@ -2,6 +2,7 @@ package handlers
import (
"fmt"
"log"
"net/url"
"regexp"
@ -37,6 +38,13 @@ func (h IndexHandler) Index(c fiber.Ctx) error {
}
}
sess := session.FromContext(c)
lastSiteID, ok := sess.Get("last_site_id").(int64)
log.Printf("last site id: %v", lastSiteID)
if ok {
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", lastSiteID))
}
site, err := h.SiteService.BestSite(c.Context(), user)
if err != nil {
return err

View file

@ -39,7 +39,6 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
var req struct {
Username string `form:"username"`
Password string `form:"password"`
LoginChallenge string `form:"_login_challenge"`
}
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body")
@ -51,11 +50,6 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
sess := session.FromContext(c)
challenge, _ := sess.Get("_login_challenge").(string)
if challenge != req.LoginChallenge {
return c.Redirect().To("/login")
}
user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password)
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")

View file

@ -9,7 +9,7 @@ import (
func LogErrors() func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
if err := c.Next(); err != nil {
log.Printf("error: %v\n", err)
log.Printf("%v: error: %v\n", c.Path(), err)
return err
}
return nil

View file

@ -5,6 +5,7 @@ import (
"emperror.dev/errors"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/sites"
@ -32,9 +33,22 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error {
return err
}
}
c.Locals("site", site)
c.SetContext(models.WithSite(c.Context(), site))
sitesOwnedByUser, err := sites.ListSites(c.Context())
if err != nil {
return err
}
c.Locals("allSites", sitesOwnedByUser)
sess := session.FromContext(c)
sess.Set("last_site_id", siteID)
if pubTargets, err := sites.BestPubTarget(c.Context(), site); err == nil {
c.Locals("pubTarget", pubTargets)
}
return c.Next()
}
}

118
handlers/pages.go Normal file
View 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})
}

View file

@ -6,6 +6,7 @@ import (
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/categories"
"lmika.dev/lmika/weiro/services/posts"
)
@ -18,22 +19,43 @@ type PostsHandler struct {
func (ph PostsHandler) Index(c fiber.Ctx) error {
var req struct {
Filter string `query:"filter"`
Page int `query:"page"`
}
if err := c.Bind().Query(&req); err != nil {
return fiber.ErrBadRequest
}
posts, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted")
const perPage = 25
if req.Page < 1 {
req.Page = 1
}
result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{
Offset: int64((req.Page - 1) * perPage),
Limit: perPage,
})
if err != nil {
return err
}
totalPages := int(result.TotalCount+int64(perPage)-1) / perPage
if totalPages < 1 {
totalPages = 1
}
pageInfo := models.PageInfo{
CurrentPage: req.Page,
TotalPages: totalPages,
PostsPerPage: perPage,
}
return accepts(c, json(func() any {
return posts
return result.Posts
}), html(func(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{
"req": req,
"posts": posts,
"posts": result.Posts,
"pageInfo": pageInfo,
})
}))
}
@ -53,7 +75,7 @@ func (ph PostsHandler) New(c fiber.Ctx) error {
"post": p,
"categories": cats,
"selectedCategories": map[int64]bool{},
"bodyClass": "post-edit-page",
"bodyClass": "large-editor",
})
}
@ -94,7 +116,7 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
"post": post,
"categories": cats,
"selectedCategories": selectedCategories,
"bodyClass": "post-edit-page",
"bodyClass": "large-editor",
})
}))
}

View file

@ -12,10 +12,28 @@ type SiteSettingsHandler struct {
SiteService *sites.Service
}
func (s *SiteSettingsHandler) General(ctx fiber.Ctx) error {
site := ctx.Locals("site").(models.Site)
func (s *SiteSettingsHandler) New(c fiber.Ctx) error {
return c.Render("sitesettings/new", fiber.Map{}, "layouts/bare_with_scripts")
}
return ctx.Render("sitesettings/general", fiber.Map{
func (s *SiteSettingsHandler) Create(c fiber.Ctx) error {
var params sites.CreateSiteParams
if err := c.Bind().Body(&params); err != nil {
return err
}
newSite, err := s.SiteService.CreateSite(c.Context(), params)
if err != nil {
return err
}
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", newSite.ID))
}
func (s *SiteSettingsHandler) General(c fiber.Ctx) error {
site := c.Locals("site").(models.Site)
return c.Render("sitesettings/general", fiber.Map{
"site": site,
"tzones": sites.ListZones(),
})

View file

@ -162,3 +162,24 @@ func (uh UploadsHandler) UploadComplete(c fiber.Ctx) error {
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{})
}
func (uh UploadsHandler) Edit(c fiber.Ctx) error {
uploadIDStr := c.Params("uploadID")
if uploadIDStr == "" {
return fiber.ErrBadRequest
}
uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
upload, err := uh.UploadsService.FetchUpload(c.Context(), uploadID)
if err != nil {
return err
}
return c.Render("uploads/edit", fiber.Map{
"upload": upload,
"bodyClass": "large-editor",
})
}

View file

@ -9,3 +9,9 @@
{{ template "_post_meta.html" . }}
</div>
{{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ url_abs .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ url_abs .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}

View file

@ -13,6 +13,13 @@
<header>
<h1>{{ .Site.Title }}</h1>
<p>{{ .Site.Tagline }}</p>
{{ if .Site.NavItems }}
<nav>
{{ range .Site.NavItems }}
{{ if .ShowInNav }}<a href="{{ url_abs .Slug }}">{{ .Title }}</a>{{ end }}
{{ end }}
</nav>
{{ end }}
</header>
<main>

View file

@ -0,0 +1,2 @@
{{ if .Page.Title }}<h2>{{ .Page.Title }}</h2>{{ end }}
{{ .HTML }}

View file

@ -6,3 +6,9 @@
{{ template "_post_meta.html" . }}
</div>
{{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ url_abs .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ url_abs .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}

View file

@ -7,4 +7,5 @@ var PermissionError = errors.New("permission denied")
var NotFoundError = errors.New("not found")
var SiteRequiredError = errors.New("site required")
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")
var UnsupportedImageFormat = errors.New("unsupported image format")

62
models/imgedit.go Normal file
View file

@ -0,0 +1,62 @@
package models
import (
"crypto/md5"
"encoding/json"
"fmt"
"strings"
"time"
)
type ImageEditSession struct {
GUID string `json:"guid"`
SiteID int64 `json:"siteId"`
UserID int64 `json:"userId"`
BaseUploadID int64 `json:"baseUploadId"`
ImageExt string `json:"imageExt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Processors []ImageEditProcessor `json:"processors"`
}
func (ieh ImageEditSession) PreviewURL() string {
return fmt.Sprintf("/sites/%v/imageedit/%v/preview/%v", ieh.SiteID, ieh.GUID, ieh.Processors[len(ieh.Processors)-1].VersionID)
}
func (ieh *ImageEditSession) RecalcVersionIDs() {
for i, p := range ieh.Processors {
if i == 0 {
p.SetVersionID("")
} else {
p.SetVersionID(ieh.Processors[i-1].VersionID)
}
ieh.Processors[i] = p
}
}
type ImageEditProcessor struct {
ID string `json:"id"`
Type string `json:"type"`
Props json.RawMessage `json:"props"`
// VersionID is a unique hash of the particular processor. This includes the version ID of the previous processor,
// thereby causing a change of one processor to affect the version IDs of processors down the line.
VersionID string `json:"versionId"`
}
func (ieh *ImageEditProcessor) SetVersionID(previousVersionID string) {
var sb strings.Builder
sb.WriteString(ieh.ID)
sb.WriteString("-")
sb.WriteString(previousVersionID)
sb.WriteString("-")
sb.WriteString(ieh.Type)
sb.WriteString("-")
sb.WriteString(string(ieh.Props))
ieh.VersionID = fmt.Sprintf("%x", md5.Sum([]byte(sb.String())))
}
type CopyUploadProps struct {
UploadID int64 `json:"uploadId"`
}

45
models/pages.go Normal file
View 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
View 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))
})
}
}

37
models/paging.go Normal file
View file

@ -0,0 +1,37 @@
package models
// PageInfo carries pagination state for templates.
type PageInfo struct {
CurrentPage int
TotalPages int
PostsPerPage int
}
// HasPrevious returns true if there is a previous page.
func (p PageInfo) HasPrevious() bool {
return p.CurrentPage > 1
}
// HasNext returns true if there is a next page.
func (p PageInfo) HasNext() bool {
return p.CurrentPage < p.TotalPages
}
// PreviousPage returns the previous page number.
func (p PageInfo) PreviousPage() int {
return p.CurrentPage - 1
}
// NextPage returns the next page number.
func (p PageInfo) NextPage() int {
return p.CurrentPage + 1
}
// Pages returns a slice of page numbers for rendering numbered pagination.
func (p PageInfo) Pages() []int {
pages := make([]int, p.TotalPages)
for i := range pages {
pages[i] = i + 1
}
return pages
}

View file

@ -6,6 +6,7 @@ import (
"iter"
"lmika.dev/lmika/weiro/models"
"lmika.dev/pkg/modash/moslice"
)
type Site struct {
@ -18,4 +19,9 @@ type Site struct {
Categories []models.CategoryWithCount
PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
Pages []*models.Page
}
func (s Site) NavItems() []*models.Page {
return moslice.Filter(s.Pages, func(p *models.Page) bool { return p.ShowInNav })
}

View file

@ -30,6 +30,7 @@ type Site struct {
Title string
Tagline string
Timezone string
PostsPerPage int
}
type SitePublishTarget struct {

101
package-lock.json generated
View file

@ -7,7 +7,9 @@
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0"
"esbuild-sass-plugin": "^3.6.0",
"feather-icons": "^4.29.2",
"handlebars": "^4.7.8"
},
"devDependencies": {
"esbuild": "0.27.3"
@ -783,6 +785,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
@ -790,6 +798,17 @@
"license": "MIT",
"peer": true
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -855,6 +874,16 @@
"sass-embedded": "^1.97.2"
}
},
"node_modules/feather-icons": {
"version": "4.29.2",
"resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz",
"integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==",
"license": "MIT",
"dependencies": {
"classnames": "^2.2.5",
"core-js": "^3.1.3"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -864,6 +893,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -887,9 +937,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"license": "MIT"
},
"node_modules/is-core-module": {
@ -930,6 +980,21 @@
"node": ">=0.10.0"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@ -1367,6 +1432,15 @@
"node": ">=14.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1434,12 +1508,31 @@
"license": "0BSD",
"peer": true
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"license": "MIT",
"peer": true
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
}
}
}

View file

@ -5,6 +5,8 @@
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0"
"esbuild-sass-plugin": "^3.6.0",
"feather-icons": "^4.29.2",
"handlebars": "^4.7.8"
}
}

View file

@ -82,8 +82,8 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([
return cats, nil
}
func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{
func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
rows, err := db.queries.SelectPublishedPostsOfCategory(ctx, sqlgen.SelectPublishedPostsOfCategoryParams{
CategoryID: categoryID,
Limit: pp.Limit,
Offset: pp.Offset,

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: categories.sql
package sqlgen
@ -227,7 +227,7 @@ func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCat
return i, err
}
const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many
const selectPublishedPostsOfCategory = `-- name: SelectPublishedPostsOfCategory :many
SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at FROM posts p
INNER JOIN post_categories pc ON pc.post_id = p.id
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
@ -235,14 +235,14 @@ ORDER BY p.published_at DESC
LIMIT ? OFFSET ?
`
type SelectPostsOfCategoryParams struct {
type SelectPublishedPostsOfCategoryParams struct {
CategoryID int64
Limit int64
Offset int64
}
func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset)
func (q *Queries) SelectPublishedPostsOfCategory(ctx context.Context, arg SelectPublishedPostsOfCategoryParams) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
package sqlgen
@ -15,6 +15,20 @@ type Category struct {
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 {
ID int64
SiteID int64
@ -64,6 +78,7 @@ type Site struct {
Tagline string
CreatedAt int64
Timezone string
PostsPerPage int64
}
type Upload struct {

View file

@ -0,0 +1,219 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.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
}

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: pending_uploads.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: posts.sql
package sqlgen
@ -9,6 +9,28 @@ import (
"context"
)
const countPostsOfSite = `-- name: CountPostsOfSite :one
SELECT COUNT(*) FROM posts
WHERE site_id = ?1 AND (
CASE CAST (?2 AS TEXT)
WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0
END
)
`
type CountPostsOfSiteParams struct {
SiteID int64
PostFilter string
}
func (q *Queries) CountPostsOfSite(ctx context.Context, arg CountPostsOfSiteParams) (int64, error) {
row := q.db.QueryRowContext(ctx, countPostsOfSite, arg.SiteID, arg.PostFilter)
var count int64
err := row.Scan(&count)
return count, err
}
const hardDeletePost = `-- name: HardDeletePost :exec
DELETE FROM posts WHERE id = ?
`
@ -178,6 +200,54 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSitePa
return items, nil
}
const selectPublishedPostsOfSite = `-- name: SelectPublishedPostsOfSite :many
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at
FROM posts
WHERE site_id = ?1 AND state = 0 AND deleted_at = 0
ORDER BY published_at DESC LIMIT ?3 OFFSET ?2
`
type SelectPublishedPostsOfSiteParams struct {
SiteID int64
Offset int64
Limit int64
}
func (q *Queries) SelectPublishedPostsOfSite(ctx context.Context, arg SelectPublishedPostsOfSiteParams) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfSite, arg.SiteID, arg.Offset, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Post
for rows.Next() {
var i Post
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.State,
&i.Guid,
&i.Title,
&i.Body,
&i.Slug,
&i.CreatedAt,
&i.UpdatedAt,
&i.PublishedAt,
&i.DeletedAt,
); 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 softDeletePost = `-- name: SoftDeletePost :exec
UPDATE posts SET deleted_at = ? WHERE id = ?
`

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: pubtargets.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: sites.sql
package sqlgen
@ -28,8 +28,9 @@ INSERT INTO sites (
title,
tagline,
timezone,
posts_per_page,
created_at
) VALUES (?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id
`
@ -39,6 +40,7 @@ type InsertSiteParams struct {
Title string
Tagline string
Timezone string
PostsPerPage int64
CreatedAt int64
}
@ -49,6 +51,7 @@ func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64,
arg.Title,
arg.Tagline,
arg.Timezone,
arg.PostsPerPage,
arg.CreatedAt,
)
var id int64
@ -101,7 +104,7 @@ func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSite
}
const selectSiteByGUID = `-- name: SelectSiteByGUID :one
SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE guid = ?
SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE guid = ?
`
func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, error) {
@ -115,12 +118,13 @@ func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, erro
&i.Tagline,
&i.CreatedAt,
&i.Timezone,
&i.PostsPerPage,
)
return i, err
}
const selectSiteByID = `-- name: SelectSiteByID :one
SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE id = ?
SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE id = ?
`
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
@ -134,12 +138,13 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
&i.Tagline,
&i.CreatedAt,
&i.Timezone,
&i.PostsPerPage,
)
return i, err
}
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE owner_id = ? ORDER BY title ASC
SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE owner_id = ? ORDER BY title ASC
`
func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) {
@ -159,6 +164,7 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
&i.Tagline,
&i.CreatedAt,
&i.Timezone,
&i.PostsPerPage,
); err != nil {
return nil, err
}
@ -174,13 +180,14 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
}
const updateSite = `-- name: UpdateSite :exec
UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ?
UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?
`
type UpdateSiteParams struct {
Title string
Tagline string
Timezone string
PostsPerPage int64
ID int64
}
@ -189,6 +196,7 @@ func (q *Queries) UpdateSite(ctx context.Context, arg UpdateSiteParams) error {
arg.Title,
arg.Tagline,
arg.Timezone,
arg.PostsPerPage,
arg.ID,
)
return err

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: uploads.sql
package sqlgen
@ -18,7 +18,7 @@ func (q *Queries) DeleteUpload(ctx context.Context, id int64) error {
return err
}
const insertUpload = `-- name: InsertUpload :exec
const insertUpload = `-- name: InsertUpload :one
INSERT INTO uploads (
site_id,
guid,
@ -43,8 +43,8 @@ type InsertUploadParams struct {
CreatedAt int64
}
func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) error {
_, err := q.db.ExecContext(ctx, insertUpload,
func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertUpload,
arg.SiteID,
arg.Guid,
arg.MimeType,
@ -54,7 +54,9 @@ func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) erro
arg.Alt,
arg.CreatedAt,
)
return err
var id int64
err := row.Scan(&id)
return id, err
}
const selectUploadByID = `-- name: SelectUploadByID :one
@ -154,3 +156,17 @@ func (q *Queries) UpdateUpload(ctx context.Context, arg UpdateUploadParams) erro
_, err := q.db.ExecContext(ctx, updateUpload, arg.Alt, arg.ID)
return err
}
const updateUploadFileSize = `-- name: UpdateUploadFileSize :exec
UPDATE uploads SET file_size = ? WHERE id = ?
`
type UpdateUploadFileSizeParams struct {
FileSize int64
ID int64
}
func (q *Queries) UpdateUploadFileSize(ctx context.Context, arg UpdateUploadFileSizeParams) error {
_, err := q.db.ExecContext(ctx, updateUploadFileSize, arg.FileSize, arg.ID)
return err
}

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// sqlc v1.30.0
// source: users.sql
package sqlgen

115
providers/db/pages.go Normal file
View 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(),
}
}

View file

@ -13,6 +13,17 @@ type PagingParams struct {
Offset int64
}
func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) {
filter := "active"
if showDeleted {
filter = "deleted"
}
return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{
SiteID: siteID,
PostFilter: filter,
})
}
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool, pp PagingParams) ([]*models.Post, error) {
var filter = ""
if showDeleted {
@ -36,6 +47,23 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDel
return posts, nil
}
func (db *Provider) SelectPublishedPostsOfSite(ctx context.Context, siteID int64, pp PagingParams) ([]*models.Post, error) {
rows, err := db.queries.SelectPublishedPostsOfSite(ctx, sqlgen.SelectPublishedPostsOfSiteParams{
SiteID: siteID,
Limit: pp.Limit,
Offset: pp.Offset,
})
if err != nil {
return nil, err
}
posts := make([]*models.Post, len(rows))
for i, row := range rows {
posts[i] = dbPostToPost(row)
}
return posts, nil
}
func (db *Provider) SelectPost(ctx context.Context, postID int64) (*models.Post, error) {
row, err := db.queries.SelectPost(ctx, postID)
if err != nil {

View file

@ -3,6 +3,7 @@ package db_test
import (
"context"
"encoding/base64"
"fmt"
"path/filepath"
"testing"
"time"
@ -229,6 +230,45 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, posts)
})
t.Run("count posts of site", func(t *testing.T) {
countSite := &models.Site{
OwnerID: user.ID,
GUID: models.NewNanoID(),
Title: "Count Blog",
}
require.NoError(t, p.SaveSite(ctx, countSite))
now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC)
for i := 0; i < 3; i++ {
post := &models.Post{
SiteID: countSite.ID,
GUID: models.NewNanoID(),
Title: fmt.Sprintf("Post %d", i),
Body: "body",
Slug: fmt.Sprintf("/post-%d", i),
CreatedAt: now,
}
require.NoError(t, p.SavePost(ctx, post))
}
count, err := p.CountPostsOfSite(ctx, countSite.ID, false)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
// Soft-delete one post
posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0})
require.NoError(t, err)
require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID))
count, err = p.CountPostsOfSite(ctx, countSite.ID, false)
require.NoError(t, err)
assert.Equal(t, int64(2), count)
count, err = p.CountPostsOfSite(ctx, countSite.ID, true)
require.NoError(t, err)
assert.Equal(t, int64(1), count)
})
}
func TestProvider_PublishTargets(t *testing.T) {

View file

@ -47,6 +47,7 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
Title: site.Title,
Tagline: site.Tagline,
Timezone: site.Timezone,
PostsPerPage: int64(site.PostsPerPage),
CreatedAt: timeToInt(site.Created),
})
if err != nil {
@ -60,6 +61,7 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
Title: site.Title,
Tagline: site.Tagline,
Timezone: site.Timezone,
PostsPerPage: int64(site.PostsPerPage),
ID: site.ID,
})
}
@ -107,6 +109,7 @@ func dbSiteToSite(row sqlgen.Site) models.Site {
Title: row.Title,
Timezone: row.Timezone,
Tagline: row.Tagline,
PostsPerPage: int(row.PostsPerPage),
Created: time.Unix(row.CreatedAt, 0).UTC(),
}
}

View file

@ -44,7 +44,7 @@ func (db *Provider) SelectUploadBySiteIDAndSlug(ctx context.Context, siteID int6
func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error {
if upload.ID == 0 {
if err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{
newID, err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{
SiteID: upload.SiteID,
Guid: upload.GUID,
MimeType: upload.MIMEType,
@ -53,9 +53,11 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error
Slug: upload.Slug,
Alt: upload.Alt,
CreatedAt: upload.CreatedAt.Unix(),
}); err != nil {
})
if err != nil {
return err
}
upload.ID = newID
return nil
}
@ -65,6 +67,13 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error
})
}
func (db *Provider) UpdateUploadFileSize(ctx context.Context, id int64, fileSize int64) error {
return db.queries.UpdateUploadFileSize(ctx, sqlgen.UpdateUploadFileSizeParams{
FileSize: fileSize,
ID: id,
})
}
func (db *Provider) DeleteUpload(ctx context.Context, id int64) error {
return db.queries.DeleteUpload(ctx, id)
}

View file

@ -22,7 +22,7 @@ type Renderer struct {
func NewRendererForUI() *Renderer {
mdParser := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithExtensions(extension.GFM, extension.Footnote),
goldmark.WithRendererOptions(
gm_html.WithUnsafe(),
),
@ -48,7 +48,7 @@ func NewRendererForUI() *Renderer {
func NewRendererForSite() *Renderer {
mdParser := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithExtensions(extension.GFM, extension.Footnote),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),

View file

@ -49,6 +49,7 @@ func New(site pubmodel.Site, opts Options) (*Builder, error) {
mdRenderer: markdown.NewRendererForSite(),
postMDProcessors: []postMDProcessor{
uploadAbsoluteURL,
removeFootnoteHRs,
},
}, nil
}
@ -118,11 +119,17 @@ func (b *Builder) BuildSite(outDir string) error {
// Build static assets
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 {
var posts []postSingleData
// Collect all posts
var allPosts []postSingleData
for mp := range b.site.PostIter(ctx) {
post, err := mp.Get()
if err != nil {
@ -132,17 +139,66 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co
if err != nil {
return err
}
posts = append(posts, rp)
allPosts = append(allPosts, rp)
}
postsPerPage := b.site.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 10
}
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
if totalPages < 1 {
totalPages = 1
}
for page := 1; page <= totalPages; page++ {
start := (page - 1) * postsPerPage
end := start + postsPerPage
if end > len(allPosts) {
end = len(allPosts)
}
pageInfo := models.PageInfo{
CurrentPage: page,
TotalPages: totalPages,
PostsPerPage: postsPerPage,
}
var prevURL, nextURL string
if page > 1 {
prevURL = fmt.Sprintf("%v/%d", b.opts.BasePostList, page-1)
}
if page < totalPages {
nextURL = fmt.Sprintf("%v/%d", b.opts.BasePostList, page+1)
}
pl := postListData{
commonData: commonData{Site: b.site},
Posts: posts,
Posts: allPosts[start:end],
PageInfo: pageInfo,
PrevURL: prevURL,
NextURL: nextURL,
}
return b.createAtPath(bctx, "", func(f io.Writer) error {
// Page 1 renders at both root and /posts/
var paths []string
if page == 1 {
paths = []string{"", fmt.Sprintf("%v/1", b.opts.BasePostList)}
} else {
paths = []string{fmt.Sprintf("%v/%d", b.opts.BasePostList, page)}
}
for _, path := range paths {
if err := b.createAtPath(bctx, path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostList, pl)
})
}); err != nil {
return err
}
}
}
return nil
}
func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]], opts feedOptions) error {
@ -318,7 +374,8 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e
continue
}
var posts []postSingleData
// Collect all posts for this category
var allPosts []postSingleData
for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) {
post, err := mp.Get()
if err != nil {
@ -328,7 +385,7 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e
if err != nil {
return err
}
posts = append(posts, rp)
allPosts = append(allPosts, rp)
}
var descHTML bytes.Buffer
@ -338,22 +395,68 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e
}
}
postsPerPage := b.site.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 10
}
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
if totalPages < 1 {
totalPages = 1
}
basePath := fmt.Sprintf("/categories/%s", cwc.Slug)
for page := 1; page <= totalPages; page++ {
start := (page - 1) * postsPerPage
end := start + postsPerPage
if end > len(allPosts) {
end = len(allPosts)
}
pageInfo := models.PageInfo{
CurrentPage: page,
TotalPages: totalPages,
PostsPerPage: postsPerPage,
}
var prevURL, nextURL string
if page > 1 {
if page == 2 {
prevURL = basePath + "/"
} else {
prevURL = fmt.Sprintf("%s/%d/", basePath, page-1)
}
}
if page < totalPages {
nextURL = fmt.Sprintf("%s/%d/", basePath, page+1)
}
path := basePath
if page > 1 {
path = fmt.Sprintf("%s/%d", basePath, page)
}
data := categorySingleData{
commonData: commonData{Site: b.site},
Category: &cwc.Category,
DescriptionHTML: template.HTML(descHTML.String()),
Posts: posts,
Path: fmt.Sprintf("/categories/%s", cwc.Slug),
Posts: allPosts[start:end],
Path: path,
PageInfo: pageInfo,
PrevURL: prevURL,
NextURL: nextURL,
}
if err := b.createAtPath(ctx, data.Path, func(f io.Writer) error {
if err := b.createAtPath(ctx, path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNameCategorySingle, data)
}); err != nil {
return err
}
}
// Per-category feeds
if err := b.renderCategoryFeed(ctx, cwc, posts); err != nil {
// Per-category feeds (use all posts, not paginated)
if err := b.renderCategoryFeed(ctx, cwc, allPosts); err != nil {
return err
}
}
@ -471,6 +574,9 @@ func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error
}
func (b *Builder) writeStaticAssets(ctx buildContext) error {
if b.opts.StaticFS == nil {
return nil
}
return fs.WalkDir(b.opts.StaticFS, ".", func(path string, d os.DirEntry, err error) error {
if err != nil {
return err

View file

@ -22,6 +22,7 @@ func TestBuilder_BuildSite(t *testing.T) {
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
"categories_list.html": {Data: []byte(`{{ range .Categories}}<a href="{{url_abs .Path}}">{{.Name}}</a>,{{ end }}`)},
"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{
@ -38,6 +39,7 @@ func TestBuilder_BuildSite(t *testing.T) {
}
site := pubmodel.Site{
Site: models.Site{PostsPerPage: 10},
BaseURL: "https://example.com",
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) {
@ -48,11 +50,15 @@ func TestBuilder_BuildSite(t *testing.T) {
}
}
},
Pages: []*models.Page{
{Title: "About", Slug: "about", Body: "About this site"},
},
}
wantFiles := map[string]string{
"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",
"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()

View file

@ -35,3 +35,8 @@ func uploadAbsoluteURL(site pubmodel.Site, dom *goquery.Document) error {
})
return nil
}
func removeFootnoteHRs(site pubmodel.Site, dom *goquery.Document) error {
dom.Find("div.footnotes > hr").Remove()
return nil
}

View 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
}

View file

@ -26,10 +26,14 @@ 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.
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.
@ -62,6 +66,9 @@ type postSingleData struct {
type postListData struct {
commonData
Posts []postSingleData
PageInfo models.PageInfo
PrevURL string
NextURL string
}
type layoutData struct {
@ -85,4 +92,13 @@ type categorySingleData struct {
DescriptionHTML template.HTML
Posts []postSingleData
Path string
PageInfo models.PageInfo
PrevURL string
NextURL string
}
type pageSingleData struct {
commonData
Page *models.Page
HTML template.HTML
}

View file

@ -66,6 +66,11 @@ func copyFile(src, dst string) error {
return err
}
func (p *Provider) ReplaceFile(site models.Site, up models.Upload, srcPath string) error {
fullPath := p.uploadFileName(site, up)
return copyFile(srcPath, fullPath)
}
func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) {
fullPath := p.uploadFileName(site, up)
return os.Open(fullPath)

View file

@ -0,0 +1,171 @@
package imgedit
import (
"context"
"encoding/json"
"fmt"
"image"
"image/color"
"os"
"path/filepath"
"github.com/disintegration/imaging"
"lmika.dev/lmika/weiro/models"
)
type imageProcessor struct {
newParams func() any
processImage func(ctx context.Context, srcImg image.Image, params any) (image.Image, error)
}
type shadowProcessorArgs struct {
Color string `json:"color"`
OffsetY int `json:"offset_y,string"`
}
var processors = map[string]imageProcessor{
"shadow": {
newParams: func() any {
return &shadowProcessorArgs{
Color: "#000000",
OffsetY: 0,
}
},
processImage: func(ctx context.Context, srcImg image.Image, params any) (image.Image, error) {
p := params.(*shadowProcessorArgs)
shadowColor, err := parseHexColor(p.Color)
if err != nil {
return nil, fmt.Errorf("invalid shadow color: %w", err)
}
shadow := makeBoxShadow(srcImg, shadowColor, 4, 10, p.OffsetY)
composit := imaging.OverlayCenter(shadow, srcImg, 1.0)
return composit, nil
},
},
}
func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSession) (imageSource, error) {
var img imageSource
for _, p := range session.Processors {
// Check if there's currently a cached image of this processor
cachedImageFile := filepath.Join(s.scratchDir, session.GUID, fmt.Sprintf("%v.%v", p.VersionID, session.ImageExt))
if s, err := os.Stat(cachedImageFile); err == nil && !s.IsDir() {
img = fileImageSource(cachedImageFile)
continue
}
// Need to process the image
var srcImg image.Image
if img != nil {
var err error
srcImg, err = img.image()
if err != nil {
return nil, err
}
}
resImg, err := s.processImage(ctx, srcImg, p)
if err != nil {
return nil, err
}
// Cache the processed image
if err := imaging.Save(resImg, cachedImageFile); err != nil {
return nil, err
}
img = imageImageSource{resImg}
}
return img, nil
}
func (s *Service) processImage(ctx context.Context, srcImg image.Image, processor models.ImageEditProcessor) (image.Image, error) {
switch processor.Type {
case "copy-upload":
var p models.CopyUploadProps
if err := json.Unmarshal(processor.Props, &p); err != nil {
return nil, err
}
_, rc, err := s.uploadService.OpenUpload(ctx, p.UploadID)
if err != nil {
return nil, err
}
f, err := rc()
if err != nil {
return nil, err
}
defer f.Close()
return imaging.Decode(f)
}
proc, ok := processors[processor.Type]
if !ok {
return nil, fmt.Errorf("unknown processor type: %v", processor.Type)
}
paramType := proc.newParams()
if err := json.Unmarshal(processor.Props, paramType); err != nil {
return nil, err
}
return proc.processImage(ctx, srcImg, paramType)
}
type imageSource interface {
image() (image.Image, error)
}
type fileImageSource string
func (f fileImageSource) image() (image.Image, error) {
return imaging.Open(string(f))
}
type imageImageSource struct {
img image.Image
}
func (i imageImageSource) image() (image.Image, error) {
return i.img, nil
}
func parseHexColor(s string) (color.Color, error) {
// Remove leading hash if present
if len(s) > 0 && s[0] == '#' {
s = s[1:]
}
// Parse based on length
var r, g, b, a uint8
switch len(s) {
case 6:
// RGB format
var rgb uint32
if _, err := fmt.Sscanf(s, "%06x", &rgb); err != nil {
return nil, fmt.Errorf("invalid hex color format: %w", err)
}
r = uint8((rgb >> 16) & 0xFF)
g = uint8((rgb >> 8) & 0xFF)
b = uint8(rgb & 0xFF)
a = 0xFF
case 8:
// RGBA format
var rgba uint32
if _, err := fmt.Sscanf(s, "%08x", &rgba); err != nil {
return nil, fmt.Errorf("invalid hex color format: %w", err)
}
r = uint8((rgba >> 24) & 0xFF)
g = uint8((rgba >> 16) & 0xFF)
b = uint8((rgba >> 8) & 0xFF)
a = uint8(rgba & 0xFF)
default:
return nil, fmt.Errorf("invalid hex color length: expected 6 or 8 characters, got %d", len(s))
}
return color.RGBA{R: r, G: g, B: b, A: a}, nil
}

266
services/imgedit/service.go Normal file
View file

@ -0,0 +1,266 @@
package imgedit
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/uploads"
"lmika.dev/pkg/modash/moslice"
)
type Service struct {
scratchDir string
uploadService *uploads.Service
sessionStore *sessionStore
}
func New(
uploadService *uploads.Service,
scratchDir string,
) *Service {
return &Service{
scratchDir: scratchDir,
uploadService: uploadService,
sessionStore: &sessionStore{baseDir: scratchDir},
}
}
func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (*models.ImageEditSession, error) {
site, user, err := s.fetchSiteAndUser(ctx)
if err != nil {
return nil, err
}
upload, _, err := s.uploadService.OpenUpload(ctx, baseUploadID)
if err != nil {
return nil, err
}
var ext string
switch upload.MIMEType {
case "image/jpeg":
ext = "jpg"
case "image/png":
ext = "png"
default:
return nil, models.UnsupportedImageFormat
}
newSession := models.ImageEditSession{
GUID: models.NewNanoID(),
SiteID: site.ID,
UserID: user.ID,
BaseUploadID: baseUploadID,
ImageExt: ext,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
Processors: []models.ImageEditProcessor{
{
ID: models.NewNanoID(),
Type: "copy-upload",
Props: mustToJSON(models.CopyUploadProps{UploadID: baseUploadID}),
},
},
}
newSession.RecalcVersionIDs()
if err := s.sessionStore.save(&newSession); err != nil {
return nil, err
}
if _, err := s.reprocess(ctx, &newSession); err != nil {
return nil, err
}
return &newSession, nil
}
func (s *Service) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err error) {
session, err := s.loadAndVerifySession(ctx, sessionID)
if err != nil {
return "", nil, err
}
return s.sessionStore.getImage(session, versionID+"."+session.ImageExt)
}
type AddProcessorReq struct {
Type string `json:"type"`
}
func (s *Service) AddProcessor(ctx context.Context, sessionID string, req AddProcessorReq) (*models.ImageEditSession, error) {
session, err := s.loadAndVerifySession(ctx, sessionID)
if err != nil {
return nil, err
}
proc, ok := processors[req.Type]
if !ok {
return nil, fmt.Errorf("unknown processor type: %v", req.Type)
}
paramType := proc.newParams()
paramBytes, err := json.Marshal(paramType)
if err != nil {
return nil, err
}
session.Processors = append(session.Processors, models.ImageEditProcessor{
ID: models.NewNanoID(),
Type: req.Type,
Props: paramBytes,
})
session.RecalcVersionIDs()
if err := s.sessionStore.save(session); err != nil {
return nil, err
}
if _, err := s.reprocess(ctx, session); err != nil {
return nil, err
}
return session, nil
}
func (s *Service) DeleteProcessor(ctx context.Context, sessionID, processorID string) (*models.ImageEditSession, error) {
session, err := s.loadAndVerifySession(ctx, sessionID)
if err != nil {
return nil, err
}
session.Processors = moslice.Filter(session.Processors, func(p models.ImageEditProcessor) bool { return p.ID != processorID })
session.RecalcVersionIDs()
if err := s.sessionStore.save(session); err != nil {
return nil, err
}
if _, err := s.reprocess(ctx, session); err != nil {
return nil, err
}
return session, nil
}
type UpdateProcessorReq struct {
ID string `json:"id"`
Props json.RawMessage `json:"props"`
}
func (s *Service) UpdateProcessor(ctx context.Context, sessionID string, req UpdateProcessorReq) (*models.ImageEditSession, error) {
session, err := s.loadAndVerifySession(ctx, sessionID)
if err != nil {
return nil, err
}
for i, p := range session.Processors {
if p.ID == req.ID {
session.Processors[i].Props = req.Props
break
}
}
session.RecalcVersionIDs()
if err := s.sessionStore.save(session); err != nil {
return nil, err
}
if _, err := s.reprocess(ctx, session); err != nil {
return nil, err
}
return session, nil
}
type SaveResult struct {
UploadID int64 `json:"upload_id"`
}
func (s *Service) Save(ctx context.Context, sessionID string, mode string) (*SaveResult, error) {
session, err := s.loadAndVerifySession(ctx, sessionID)
if err != nil {
return nil, err
}
if len(session.Processors) == 0 {
return nil, fmt.Errorf("no processors in session")
}
lastProc := session.Processors[len(session.Processors)-1]
finalImagePath := fmt.Sprintf("%v/%v/%v.%v", s.scratchDir, session.GUID, lastProc.VersionID, session.ImageExt)
var mimeType string
switch session.ImageExt {
case "jpg", "jpeg":
mimeType = "image/jpeg"
case "png":
mimeType = "image/png"
}
var uploadID int64
switch mode {
case "replace":
upload, err := s.uploadService.ReplaceUploadFile(ctx, session.BaseUploadID, finalImagePath)
if err != nil {
return nil, err
}
uploadID = upload.ID
case "copy":
baseUpload, _, err := s.uploadService.OpenUpload(ctx, session.BaseUploadID)
if err != nil {
return nil, err
}
upload, err := s.uploadService.CreateUploadFromFile(ctx, finalImagePath, baseUpload.Filename, mimeType)
if err != nil {
return nil, err
}
uploadID = upload.ID
default:
return nil, fmt.Errorf("unknown save mode: %v", mode)
}
s.sessionStore.delete(session.GUID)
return &SaveResult{UploadID: uploadID}, nil
}
func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) {
site, user, err := s.fetchSiteAndUser(ctx)
if err != nil {
return nil, err
}
session, err := s.sessionStore.get(sessionID)
if err != nil {
return nil, err
} else if session.SiteID != site.ID || session.UserID != user.ID {
return nil, models.PermissionError
}
return session, nil
}
func (s *Service) fetchSiteAndUser(ctx context.Context) (models.Site, models.User, error) {
user, ok := models.GetUser(ctx)
if !ok {
return models.Site{}, models.User{}, models.UserRequiredError
}
site, ok := models.GetSite(ctx)
if !ok {
return models.Site{}, models.User{}, models.SiteRequiredError
}
if site.OwnerID != user.ID {
return models.Site{}, models.User{}, models.PermissionError
}
return site, user, nil
}
func mustToJSON(a any) json.RawMessage {
b, _ := json.Marshal(a)
return b
}

View file

@ -0,0 +1,35 @@
package imgedit
import (
"image"
"image/color"
"github.com/disintegration/imaging"
)
func makeBoxShadow(maskImg image.Image, shadowColor color.Color, sigma float64, shadowMargin, offsetY int) image.Image {
w, h := maskImg.Bounds().Dx(), maskImg.Bounds().Dy()
cr, cg, cb, _ := shadowColor.RGBA()
cr8, cg8, cb8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8)
// New box image
backing := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY))
newImg := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY))
for x := 0; x < w+shadowMargin*2; x++ {
for y := 0; y < h+shadowMargin*2; y++ {
var c = color.NRGBA{R: 255, G: 255, B: 255, A: 0}
if x >= shadowMargin-4 && y >= shadowMargin-4 && x <= w+shadowMargin+4 && y <= h+shadowMargin+4 {
_, _, _, a := maskImg.At(x-shadowMargin, y-shadowMargin).RGBA()
c = color.NRGBA{R: cr8, G: cg8, B: cb8, A: uint8(a >> 8)}
}
backing.SetNRGBA(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: 0})
newImg.SetNRGBA(x, y+offsetY, c)
}
}
// Blur
blurredImage := imaging.Blur(newImg, sigma)
backing = imaging.OverlayCenter(backing, blurredImage, 0.6)
return backing
}

70
services/imgedit/store.go Normal file
View file

@ -0,0 +1,70 @@
package imgedit
import (
"encoding/json"
"io"
"os"
"path/filepath"
"lmika.dev/lmika/weiro/models"
)
type sessionStore struct {
baseDir string
}
func (ss *sessionStore) save(newSession *models.ImageEditSession) error {
sessionMeta, err := json.Marshal(newSession)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(ss.baseDir, newSession.GUID), 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(ss.baseDir, newSession.GUID, "session.json"), sessionMeta, 0644); err != nil {
return err
}
return nil
}
func (ss *sessionStore) get(guid string) (*models.ImageEditSession, error) {
sessionDataBts, err := os.ReadFile(filepath.Join(ss.baseDir, guid, "session.json"))
if err != nil {
return nil, err
}
sessionData := models.ImageEditSession{}
if err := json.Unmarshal(sessionDataBts, &sessionData); err != nil {
return nil, err
}
return &sessionData, nil
}
func (ss *sessionStore) delete(guid string) {
os.RemoveAll(filepath.Join(ss.baseDir, guid))
}
func (ss *sessionStore) getImage(session *models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) {
fullPath := filepath.Join(ss.baseDir, session.GUID, imageFilename)
if s, err := os.Stat(fullPath); err != nil {
return "", nil, err
} else if s.IsDir() {
return "", nil, os.ErrNotExist
}
var mimeType string
switch filepath.Ext(imageFilename) {
case ".jpg", ".jpeg":
mimeType = "image/jpeg"
case ".png":
mimeType = "image/png"
default:
return "", nil, models.UnsupportedImageFormat
}
return mimeType, func() (io.ReadCloser, error) {
return os.Open(fullPath)
}, nil
}

198
services/pages/service.go Normal file
View 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
}

View file

@ -12,29 +12,36 @@ type PostWithCategories struct {
Categories []*models.Category
}
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
type ListPostsResult struct {
Posts []*PostWithCategories
TotalCount int64
}
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{
Offset: 0,
Limit: 25,
})
func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) {
site, ok := models.GetSite(ctx)
if !ok {
return ListPostsResult{}, models.SiteRequiredError
}
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging)
if err != nil {
return nil, err
return ListPostsResult{}, err
}
count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted)
if err != nil {
return ListPostsResult{}, err
}
result := make([]*PostWithCategories, len(posts))
for i, post := range posts {
cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID)
if err != nil {
return nil, err
return ListPostsResult{}, err
}
result[i] = &PostWithCategories{Post: post, Categories: cats}
}
return result, nil
return ListPostsResult{Posts: result, TotalCount: count}, nil
}
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {

View file

@ -9,10 +9,10 @@ import (
)
// postIter returns a post iterator which returns posts in reverse chronological order.
func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
func (s *Publisher) publishedPostIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) {
paging := db.PagingParams{Offset: 0, Limit: 50}
page, err := s.db.SelectPostsOfSite(ctx, site, false, paging)
page, err := s.db.SelectPublishedPostsOfSite(ctx, site, paging)
if err != nil {
yield(models.Maybe[*models.Post]{Err: err})
return
@ -45,7 +45,7 @@ func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) it
return func(yield func(models.Maybe[*models.Post]) bool) {
paging := db.PagingParams{Offset: 0, Limit: 50}
for {
page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging)
page, err := s.db.SelectPublishedPostsOfCategory(ctx, categoryID, paging)
if err != nil {
yield(models.Maybe[*models.Post]{Err: err})
return

View file

@ -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
@ -73,7 +79,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
pubSite := pubmodel.Site{
Site: site,
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
return p.postIter(ctx, site.ID)
return p.publishedPostIter(ctx, site.ID)
},
BaseURL: target.BaseURL,
Uploads: uploads,
@ -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)
},
@ -115,6 +122,7 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ
sb, err := sitebuilder.New(pubSite, sitebuilder.Options{
BasePosts: "/posts",
BasePostList: "/pages",
BaseUploads: "/uploads",
BaseStatic: "/static",
TemplatesFS: templateFS,

View file

@ -8,6 +8,8 @@ import (
"lmika.dev/lmika/weiro/providers/uploadfiles"
"lmika.dev/lmika/weiro/services/auth"
"lmika.dev/lmika/weiro/services/categories"
"lmika.dev/lmika/weiro/services/imgedit"
"lmika.dev/lmika/weiro/services/pages"
"lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher"
"lmika.dev/lmika/weiro/services/sites"
@ -22,7 +24,9 @@ type Services struct {
Posts *posts.Service
Sites *sites.Service
Uploads *uploads.Service
ImageEdit *imgedit.Service
Categories *categories.Service
Pages *pages.Service
}
func New(cfg config.Config) (*Services, error) {
@ -39,7 +43,9 @@ func New(cfg config.Config) (*Services, error) {
postService := posts.New(dbp, publisherQueue)
siteService := sites.New(dbp)
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit"))
categoriesService := categories.New(dbp, publisherQueue)
pagesService := pages.New(dbp, publisherQueue)
return &Services{
DB: dbp,
@ -49,7 +55,9 @@ func New(cfg config.Config) (*Services, error) {
Posts: postService,
Sites: siteService,
Uploads: uploadService,
ImageEdit: imageEditService,
Categories: categoriesService,
Pages: pagesService,
}, nil
}

View file

@ -9,6 +9,7 @@ import (
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/pkg/modash/moslice"
)
type Service struct {
@ -25,6 +26,22 @@ func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) {
return s.db.HasUsersAndSites(ctx)
}
func (s *Service) ListSites(ctx context.Context) ([]models.Site, error) {
user, ok := models.GetUser(ctx)
if !ok {
return nil, models.UserRequiredError
}
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
if err != nil {
return nil, err
} else if len(sites) == 0 {
return nil, errors.New("no sites found")
}
return sites, nil
}
func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) {
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
if err != nil {
@ -36,16 +53,20 @@ func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site,
return sites[0], nil
}
type FirstRunRequest struct {
Username string `form:"username"`
Password1 string `form:"password1"`
Password2 string `form:"password2"`
type CreateSiteParams struct {
SiteName string `form:"siteName"`
SiteURL string `form:"siteUrl"`
NetlifySiteID string `form:"netlifySiteId"`
NetlifyAPIKey string `form:"netlifyAPIToken"`
}
type FirstRunRequest struct {
CreateSiteParams
Username string `form:"username"`
Password1 string `form:"password1"`
Password2 string `form:"password2"`
}
func (frr FirstRunRequest) Validate() error {
return validation.ValidateStruct(&frr,
validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)),
@ -76,15 +97,31 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo
return newUser, newSite, err
}
ctx = models.WithUser(ctx, newUser)
newSite, err = s.CreateSite(ctx, req.CreateSiteParams)
if err != nil {
return newUser, newSite, err
}
return newUser, newSite, nil
}
func (s *Service) CreateSite(ctx context.Context, req CreateSiteParams) (newSite models.Site, _ error) {
user, ok := models.GetUser(ctx)
if !ok {
return newSite, models.UserRequiredError
}
newSite = models.Site{
Title: defaultIfEmpty(req.SiteName, "New Site"),
GUID: models.NewNanoID(),
OwnerID: newUser.ID,
OwnerID: user.ID,
Timezone: "UTC",
PostsPerPage: 10,
Created: time.Now(),
}
if err := s.db.SaveSite(ctx, &newSite); err != nil {
return newUser, newSite, err
return newSite, err
}
hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != ""
@ -99,11 +136,11 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo
TargetKey: req.NetlifyAPIKey,
}
if err := s.db.SavePublishTarget(ctx, &target); err != nil {
return newUser, newSite, err
return newSite, err
}
}
return newUser, newSite, nil
return newSite, nil
}
func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) {
@ -133,6 +170,7 @@ type UpdateSiteSettingsParams struct {
Name string `form:"name"`
Tagline string `form:"tagline"`
Timezone string `form:"timezone"`
PostsPerPage int `form:"postsPerPage"`
}
func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) {
@ -146,9 +184,17 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti
return models.Site{}, errors.Wrap(err, "invalid timezone")
}
postsPerPage := params.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 1
} else if postsPerPage > 100 {
postsPerPage = 100
}
site.Title = params.Name
site.Tagline = params.Tagline
site.Timezone = params.Timezone
site.PostsPerPage = postsPerPage
if err := s.db.SaveSite(ctx, &site); err != nil {
return models.Site{}, err
@ -156,3 +202,17 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti
return site, nil
}
func (s *Service) BestPubTarget(ctx context.Context, site models.Site) (models.SitePublishTarget, error) {
pubTargets, err := s.db.SelectPublishTargetsOfSite(ctx, site.ID)
if err != nil {
return models.SitePublishTarget{}, err
}
enabledPubTargets := moslice.Filter(pubTargets, func(pubTarget models.SitePublishTarget) bool { return pubTarget.Enabled })
if len(enabledPubTargets) == 0 {
return models.SitePublishTarget{}, errors.New("no publish targets found")
}
return enabledPubTargets[0], nil
}

View file

@ -6,7 +6,10 @@ import (
"html/template"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"lmika.dev/lmika/weiro/models"
)
@ -67,6 +70,75 @@ func (s *Service) renderCopyTemplate(upload models.Upload) string {
return sb.String()
}
func (s *Service) ReplaceUploadFile(ctx context.Context, uploadID int64, srcPath string) (models.Upload, error) {
site, _, err := s.fetchSiteAndUser(ctx)
if err != nil {
return models.Upload{}, err
}
upload, err := s.db.SelectUploadByID(ctx, uploadID)
if err != nil {
return models.Upload{}, err
} else if upload.SiteID != site.ID {
return models.Upload{}, models.NotFoundError
}
if err := s.up.ReplaceFile(site, upload, srcPath); err != nil {
return models.Upload{}, err
}
stat, err := os.Stat(srcPath)
if err != nil {
return models.Upload{}, err
}
upload.FileSize = stat.Size()
if err := s.db.UpdateUploadFileSize(ctx, upload.ID, upload.FileSize); err != nil {
return models.Upload{}, err
}
return upload, nil
}
func (s *Service) CreateUploadFromFile(ctx context.Context, srcPath string, filename string, mimeType string) (models.Upload, error) {
site, _, err := s.fetchSiteAndUser(ctx)
if err != nil {
return models.Upload{}, err
}
stat, err := os.Stat(srcPath)
if err != nil {
return models.Upload{}, err
}
newUploadGUID := models.NewNanoID()
newTime := time.Now().UTC()
newSlug := filepath.Join(
fmt.Sprintf("%04d", newTime.Year()),
fmt.Sprintf("%02d", newTime.Month()),
newUploadGUID+filepath.Ext(filename),
)
newUpload := models.Upload{
SiteID: site.ID,
GUID: models.NewNanoID(),
FileSize: stat.Size(),
MIMEType: mimeType,
Filename: filename,
CreatedAt: newTime,
Slug: newSlug,
}
if err := s.db.SaveUpload(ctx, &newUpload); err != nil {
return models.Upload{}, err
}
if err := s.up.AdoptFile(site, newUpload, srcPath); err != nil {
return models.Upload{}, err
}
return newUpload, nil
}
func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error) {
site, _, err := s.fetchSiteAndUser(ctx)
if err != nil {

View file

@ -17,7 +17,7 @@ INNER JOIN post_categories pc ON pc.category_id = c.id
WHERE pc.post_id = ?
ORDER BY c.name ASC;
-- name: SelectPostsOfCategory :many
-- name: SelectPublishedPostsOfCategory :many
SELECT p.* FROM posts p
INNER JOIN post_categories pc ON pc.post_id = p.id
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0

34
sql/queries/pages.sql Normal file
View 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 = ?;

View file

@ -1,3 +1,12 @@
-- name: CountPostsOfSite :one
SELECT COUNT(*) FROM posts
WHERE site_id = sqlc.arg(site_id) AND (
CASE CAST (sqlc.arg(post_filter) AS TEXT)
WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0
END
);
-- name: SelectPostsOfSite :many
SELECT *
FROM posts
@ -8,6 +17,12 @@ WHERE site_id = sqlc.arg(site_id) AND (
END
) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
-- name: SelectPublishedPostsOfSite :many
SELECT *
FROM posts
WHERE site_id = sqlc.arg(site_id) AND state = 0 AND deleted_at = 0
ORDER BY published_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
-- name: SelectPost :one
SELECT * FROM posts WHERE id = ? LIMIT 1;

View file

@ -14,15 +14,16 @@ INSERT INTO sites (
title,
tagline,
timezone,
posts_per_page,
created_at
) VALUES (?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id;
-- name: HasUsersAndSites :one
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites;
-- name: UpdateSite :exec
UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ?;
UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?;
-- name: SelectAllSitesWithOwners :many
SELECT s.id, s.guid, s.title, s.owner_id, u.username

View file

@ -7,7 +7,7 @@ SELECT * FROM uploads WHERE id = ? LIMIT 1;
-- name: SelectUploadBySiteIDAndSlug :one
SELECT * FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1;
-- name: InsertUpload :exec
-- name: InsertUpload :one
INSERT INTO uploads (
site_id,
guid,
@ -23,5 +23,8 @@ RETURNING id;
-- name: UpdateUpload :exec
UPDATE uploads SET alt = ? WHERE id = ?;
-- name: UpdateUploadFileSize :exec
UPDATE uploads SET file_size = ? WHERE id = ?;
-- name: DeleteUpload :exec
DELETE FROM uploads WHERE id = ?;

View file

@ -0,0 +1 @@
ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10;

View 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);

View file

@ -11,11 +11,14 @@
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
</li>
<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 class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/uploads">Uploads</a>
</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">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/settings">Settings</a>
</li>
@ -26,7 +29,25 @@
<span class="visually-hidden">Publishing...</span>
</div>
-->
<div class="nav-item dropdown me-2">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ .site.Title }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
{{ range .allSites }}
<li><a class="dropdown-item" href="/sites/{{.ID}}/posts">{{.Title}}</a></li>
{{ end }}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/sites/new">New Site…</a></li>
</ul>
</div>
<div class="nav-item dropdown border-end me-3">
{{ if .pubTarget }}
<a href="{{.pubTarget.BaseURL}}" class="nav-link navbar-site-visit" target="_blank" title="Visit site">
<i data-feather="external-link" width="18" height="18"></i>
</a>
{{ end }}
</div>
<div class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ .user.Username }}

View file

@ -26,7 +26,7 @@
</div>
<div data-first-run-target="pages">
<div class="text-center mb-4">
<p>Enter the details of your blog, if you know them.<br>All fields are optional, and can be changed later.</p>
<p>Enter the details of your blog if you know them.<br>All fields are optional and can be changed later.</p>
</div>
<div class="mb-2">
<label for="siteName" class="form-label">Site Name</label>

55
views/pages/edit.html Normal file
View 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
View 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;">&#x2630;</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">📄<br>No pages yet.</div>
</div>
{{ end }}
</main>

View file

@ -1,5 +1,5 @@
{{ $isPublished := ne .post.State 1 }}
<main class="flex-grow-1 position-relative">
<main class="flex-grow-1 position-relative post-edit-page">
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form py-2"
data-controller="postedit"
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
@ -11,12 +11,12 @@
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
</div>
<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 }}
<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 }}
<input type="submit" name="action" class="btn btn-primary mt-2" value="Publish">
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
<input type="submit" name="action" class="btn btn-primary" value="Publish">
<input type="submit" name="action" class="btn btn-secondary" value="Save Draft">
{{ end }}
</div>
</div>

View file

@ -62,4 +62,22 @@
{{ end }}
</div>
{{ end }}
{{ if gt .pageInfo.TotalPages 1 }}
<nav aria-label="Page navigation" class="my-4">
<ul class="pagination justify-content-center">
<li class="page-item{{ if not .pageInfo.HasPrevious }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.PreviousPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Previous</a>
</li>
{{ range $p := .pageInfo.Pages }}
<li class="page-item{{ if eq $p $.pageInfo.CurrentPage }} active{{ end }}">
<a class="page-link" href="?page={{ $p }}{{ if $.req.Filter }}&filter={{ $.req.Filter }}{{ end }}">{{ $p }}</a>
</li>
{{ end }}
<li class="page-item{{ if not .pageInfo.HasNext }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.NextPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Next</a>
</li>
</ul>
</nav>
{{ end }}
</main>

View file

@ -41,6 +41,13 @@
</datalist>
</div>
</div>
<div class="row mb-3">
<label for="postsPerPage" class="col-sm-3 col-form-label text-end">Posts Per Page</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="postsPerPage" name="postsPerPage" value="{{ .site.PostsPerPage }}" min="1" max="100">
<div class="form-text">Number of posts per page on the generated site.</div>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3"></div>
<div class="col-sm-9"><button type="submit" class="btn btn-primary">Save Settings</button></div>

View file

@ -0,0 +1,29 @@
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 50px;" data-controller="first-run">
<div class="text-center mb-4">
<h1>New Site</h1>
</div>
<form action="/sites" method="post">
<div class="text-center mb-4">
<p>Enter the details of your blog if you know them.<br>All fields are optional and can be changed later.</p>
</div>
<div class="mb-2">
<label for="siteName" class="form-label">Site Name</label>
<input type="text" class="form-control" name="siteName" id="siteName">
</div>
<div class="mb-3">
<label for="siteUrl" class="form-label">Site URL</label>
<input type="text" class="form-control" name="siteUrl" id="siteUrl">
</div>
<div class="mb-3">
<label for="netlifySiteId" class="form-label">Netlify Site ID</label>
<input type="text" class="form-control" name="netlifySiteId" id="netlifySiteId">
</div>
<div class="mb-3">
<label for="netlifyAPIToken" class="form-label">Netlify API Token</label>
<input type="text" class="form-control" name="netlifyAPIToken" id="netlifyAPIToken">
</div>
<div class="mb-3 text-end">
<input type="submit" class="btn btn-primary" value="Create Site">
</div>
</form>
</div>

31
views/uploads/edit.html Normal file
View file

@ -0,0 +1,31 @@
<main class="flex-grow-1 flex-shrink-1"
data-controller="edit-upload"
data-edit-upload-site-id-value="{{ .site.ID }}"
data-edit-upload-upload-id-value="{{ .upload.Upload.ID }}"
>
<div class="flex-grow-1 flex-shrink-1 d-flex flex-column">
<div class="row flex-grow-1 flex-shrink-1 m-0">
<figure class="col-md-9 p-3">
<img data-edit-upload-target="preview" src="{{ .upload.URL }}" alt="{{ .upload.Upload.Alt }}" class="img-fluid" style="max-height: 80vw;">
</figure>
<div class="col-md-3 p-3">
<div data-edit-upload-target="processList"></div>
<div class="text-center">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Add Processor
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" data-action="edit-upload#addProcessor">Shadow</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-9 m-3">
<button class="btn btn-primary" data-action="edit-upload#saveUpload">Save</button>
<button class="btn btn-secondary" data-action="edit-upload#saveNewUpload">Save as Copy</button>
</div>
</main>

View file

@ -20,5 +20,9 @@
</div>
{{ end }}
</div>
{{ else }}
<div class="h4 m-3 text-center">
<div class="position-absolute top-50 start-50 translate-middle">🖼️<br>No uploads yet.</div>
</div>
{{ end }}
</main>

View file

@ -5,7 +5,10 @@
data-show-upload-site-id-value="{{ .upload.Upload.SiteID }}"
data-show-upload-upload-id-value="{{ .upload.Upload.ID }}">
<button class="btn btn-outline-dark" data-action="show-upload#copy">Copy HTML</button>
<span>
<a href="/sites/{{ .site.ID }}/uploads/{{ .upload.Upload.ID }}/edit" class="btn btn-secondary">Edit</a>
<button class="btn btn-danger" data-action="show-upload#delete">Delete</button>
</span>
</div>
<figure>