feat(15-02): add Categories, Tags, and journal settings admin

Register the remaining YAML admin screens and the settings singleton
so a host can boot Posts, Categories, Tags, and Code journal. Posts
update and delete refuse other authors without access_other_posts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 18:42:29 +02:00
parent dea0934950
commit afd0a2cafc
17 changed files with 388 additions and 3 deletions

View File

@@ -5,6 +5,7 @@ import (
"io/fs"
"git.golem15.com/golem15/sm-journal-plugin/controllers"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -13,6 +14,9 @@ import (
// directory also holds Go sources that are not admin assets.
//
//go:embed controllers/posts/config_list.yaml controllers/posts/config_form.yaml controllers/posts/config_filter.yaml models/post/fields.yaml models/post/columns.yaml
//go:embed controllers/categories/config_list.yaml controllers/categories/config_form.yaml models/category/fields.yaml models/category/columns.yaml
//go:embed controllers/tags/config_list.yaml controllers/tags/config_form.yaml models/tag/fields.yaml models/tag/columns.yaml
//go:embed models/settings/fields.yaml
//go:embed assets/images/journal-icon.svg
var adminFS embed.FS
@@ -29,9 +33,23 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
})
}
func (p *Plugin) Settings() []pact.SettingsItem {
return []pact.SettingsItem{{
Code: "journal",
Label: "golem15.journal::lang.journal.menu_label",
Description: "golem15.journal::lang.journal.settings_description",
Category: "golem15.journal::lang.journal.menu_label",
Icon: "icon-pencil",
Permissions: []string{"golem15.journal.manage_settings"},
Form: "models/settings/fields.yaml",
NewModel: func() any { return &models.Settings{} },
}}
}
var (
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
_ pact.HasSettings = (*Plugin)(nil)
)

View File

@@ -6,9 +6,7 @@ import (
)
// Navigation is the plugin's Winter registerNavigation() entry: the main
// item journal (order 300) with its side menu. Categories and tags side
// items are added when those controllers register (Task 2): cabana boot
// refuses a NavigationItem.Controller that is not an AdminController.
// item journal (order 300) with its side menu.
func (*Plugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{
{
@@ -33,6 +31,20 @@ func (*Plugin) Navigation() []pact.NavigationItem {
Permissions: []string{controllers.PermissionAccessPosts},
Controller: controllers.PostsControllerID,
},
{
Code: "categories",
Label: "golem15.journal::lang.journal.categories",
Icon: "list",
Permissions: []string{controllers.PermissionAccessCategories},
Controller: controllers.CategoriesControllerID,
},
{
Code: "tags",
Label: "golem15.journal::lang.journal.tags",
Icon: "tag",
Permissions: []string{controllers.PermissionAccessTags},
Controller: controllers.TagsControllerID,
},
},
},
}

View File

@@ -12,6 +12,8 @@ import (
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
postsAdminController{app: app},
categoriesAdminController{app: app},
tagsAdminController{app: app},
}
}
@@ -25,5 +27,15 @@ func init() {
var _ pact.FilterOptions = postsAdminController{}
var _ pact.FormBeforeCreate = postsAdminController{}
var _ pact.FormBeforeUpdate = postsAdminController{}
var _ pact.FormBeforeDelete = postsAdminController{}
var _ cabana.FieldRelationProvider = postsAdminController{}
var _ pact.AdminController = categoriesAdminController{}
var _ pact.AdminPermissioned = categoriesAdminController{}
var _ pact.AdminRecordSource = categoriesAdminController{}
var _ cabana.FieldRelationProvider = categoriesAdminController{}
var _ pact.AdminController = tagsAdminController{}
var _ pact.AdminPermissioned = tagsAdminController{}
var _ pact.AdminRecordSource = tagsAdminController{}
}

48
controllers/categories.go Normal file
View File

@@ -0,0 +1,48 @@
package controllers
import (
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
const (
// CategoriesControllerID is the cabana controller id of the Categories screen.
CategoriesControllerID = "golem15.journal.categories"
// PermissionAccessCategories is Categories::$requiredPermissions.
PermissionAccessCategories = "golem15.journal.access_categories"
)
type categoriesAdminController struct {
app func() *backpack.App
}
func (categoriesAdminController) ID() string { return CategoriesControllerID }
func (categoriesAdminController) ModelName() string { return `Golem15\Journal\Models\Category` }
func (categoriesAdminController) ConfigDir() string { return "controllers/categories" }
func (categoriesAdminController) RequiredPermissions() []string {
return []string{PermissionAccessCategories}
}
func (categoriesAdminController) NewRecord() any { return &models.Category{} }
func (categoriesAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{
Field: "parent",
Kind: "belongsTo",
NewRelated: func() any { return &models.Category{} },
ForeignKey: "parent_id",
LabelColumn: "name",
}}
}
var (
_ pact.AdminController = categoriesAdminController{}
_ pact.AdminPermissioned = categoriesAdminController{}
_ pact.AdminRecordSource = categoriesAdminController{}
_ cabana.FieldRelationProvider = categoriesAdminController{}
)

View File

@@ -0,0 +1,19 @@
name: golem15.journal::lang.journal.create_category
form: ~/plugins/golem15/journal/models/category/fields.yaml
modelClass: Golem15\Journal\Models\Category
defaultRedirect: golem15/journal/categories
create:
redirect: golem15/journal/categories/update/:id
redirectClose: golem15/journal/categories
update:
redirect: golem15/journal/categories
redirectClose: golem15/journal/categories
messages:
create: golem15.journal::lang.journal.create_category
update: backend::lang.messages.form.update
saved: backend::lang.messages.form.saved
deleteConfirm: golem15.journal::lang.category.delete_confirm
deleted: backend::lang.messages.form.deleted

View File

@@ -0,0 +1,19 @@
list: ~/plugins/golem15/journal/models/category/columns.yaml
modelClass: Golem15\Journal\Models\Category
title: golem15.journal::lang.category.label_plural
recordUrl: golem15/journal/categories/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20
showCheckboxes: true
showSetup: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
create: golem15.journal::lang.journal.create_category
empty: backend::lang.list.no_records
deleteConfirm: golem15.journal::lang.category.delete_confirm
deleted: backend::lang.messages.list.deleted

View File

@@ -174,9 +174,28 @@ func (c postsAdminController) FormBeforeUpdate(ctx context.Context, model any) e
if !ok || post == nil {
return errors.New("posts admin: form model is not a post")
}
if err := refuseOtherAuthor(ctx, post); err != nil {
return err
}
return preparePostWrite(ctx, post)
}
func (c postsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
post, ok := model.(*models.Post)
if !ok || post == nil {
return errors.New("posts admin: form model is not a post")
}
return refuseOtherAuthor(ctx, post)
}
func refuseOtherAuthor(ctx context.Context, post *models.Post) error {
principal, _ := bouncer.User(ctx)
if post.CanEdit(principal) {
return nil
}
return &cabana.ForbiddenError{Message: "golem15.journal::lang.journal.access_other_posts"}
}
func preparePostWrite(ctx context.Context, post *models.Post) error {
if err := refuseUnpublishedWrite(ctx, post); err != nil {
return err

36
controllers/tags.go Normal file
View File

@@ -0,0 +1,36 @@
package controllers
import (
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
)
const (
// TagsControllerID is the cabana controller id of the Tags screen.
TagsControllerID = "golem15.journal.tags"
// PermissionAccessTags is Tags::$requiredPermissions.
PermissionAccessTags = "golem15.journal.access_tags"
)
type tagsAdminController struct {
app func() *backpack.App
}
func (tagsAdminController) ID() string { return TagsControllerID }
func (tagsAdminController) ModelName() string { return `Golem15\Journal\Models\Tag` }
func (tagsAdminController) ConfigDir() string { return "controllers/tags" }
func (tagsAdminController) RequiredPermissions() []string {
return []string{PermissionAccessTags}
}
func (tagsAdminController) NewRecord() any { return &models.Tag{} }
var (
_ pact.AdminController = tagsAdminController{}
_ pact.AdminPermissioned = tagsAdminController{}
_ pact.AdminRecordSource = tagsAdminController{}
)

View File

@@ -0,0 +1,19 @@
name: golem15.journal::lang.journal.create_tag
form: ~/plugins/golem15/journal/models/tag/fields.yaml
modelClass: Golem15\Journal\Models\Tag
defaultRedirect: golem15/journal/tags
create:
redirect: golem15/journal/tags/update/:id
redirectClose: golem15/journal/tags
update:
redirect: golem15/journal/tags
redirectClose: golem15/journal/tags
messages:
create: golem15.journal::lang.journal.create_tag
update: backend::lang.messages.form.update
saved: backend::lang.messages.form.saved
deleteConfirm: golem15.journal::lang.tag.delete_confirm
deleted: backend::lang.messages.form.deleted

View File

@@ -0,0 +1,19 @@
list: ~/plugins/golem15/journal/models/tag/columns.yaml
modelClass: Golem15\Journal\Models\Tag
title: golem15.journal::lang.tag.label_plural
recordUrl: golem15/journal/tags/update/:id
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20
showCheckboxes: true
showSetup: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
create: golem15.journal::lang.journal.create_tag
empty: backend::lang.list.no_records
deleteConfirm: golem15.journal::lang.tag.delete_confirm
deleted: backend::lang.messages.list.deleted

View File

@@ -31,6 +31,13 @@ func (Category) Fillable() []string {
return []string{"name", "slug", "code", "description", "parent_id"}
}
func (Category) Rules() map[string]string {
return map[string]string{
"name": "required",
"slug": "required",
}
}
func init() {
Register(Category{})
}

View File

@@ -0,0 +1,7 @@
columns:
name:
label: golem15.journal::lang.category.name
searchable: true
slug:
label: golem15.journal::lang.category.slug
searchable: true

View File

@@ -0,0 +1,21 @@
fields:
name:
label: golem15.journal::lang.category.name
type: mltext
span: left
required: true
slug:
label: golem15.journal::lang.category.slug
type: mltext
span: right
required: true
parent:
label: Parent
type: relation
nameFrom: name
span: full
description:
label: golem15.journal::lang.category.description
type: mltext
size: large
span: full

View File

@@ -0,0 +1,96 @@
fields:
show_all_posts:
span: left
label: golem15.journal::lang.journal.show_all_posts_label
comment: golem15.journal::lang.journal.show_all_posts_comment
type: switch
default: true
tab: golem15.journal::lang.journal.tab_general
use_rich_editor:
span: left
label: golem15.journal::lang.journal.use_rich_editor_label
comment: golem15.journal::lang.journal.use_rich_editor_comment
type: switch
default: false
tab: golem15.journal::lang.journal.tab_general
search_use_typesense:
span: left
label: golem15.journal::lang.journal.search_use_typesense_label
comment: golem15.journal::lang.journal.search_use_typesense_comment
type: switch
default: false
tab: golem15.journal::lang.journal.tab_search
search_title_weight:
span: left
label: golem15.journal::lang.journal.search_title_weight_label
comment: golem15.journal::lang.journal.search_title_weight_comment
type: number
default: 5
tab: golem15.journal::lang.journal.tab_search
search_excerpt_weight:
span: left
label: golem15.journal::lang.journal.search_excerpt_weight_label
comment: golem15.journal::lang.journal.search_excerpt_weight_comment
type: number
default: 3
tab: golem15.journal::lang.journal.tab_search
search_content_weight:
span: left
label: golem15.journal::lang.journal.search_content_weight_label
comment: golem15.journal::lang.journal.search_content_weight_comment
type: number
default: 1
tab: golem15.journal::lang.journal.tab_search
rss_enabled:
span: left
label: golem15.journal::lang.journal.rss_enabled_label
comment: golem15.journal::lang.journal.rss_enabled_comment
type: switch
default: true
tab: golem15.journal::lang.journal.tab_rss
rss_include_content:
span: right
label: golem15.journal::lang.journal.rss_include_content_label
comment: golem15.journal::lang.journal.rss_include_content_comment
type: switch
default: false
tab: golem15.journal::lang.journal.tab_rss
rss_title:
span: left
label: golem15.journal::lang.journal.rss_title_label
comment: golem15.journal::lang.journal.rss_title_comment
type: text
tab: golem15.journal::lang.journal.tab_rss
rss_description:
span: right
label: golem15.journal::lang.journal.rss_description_label
comment: golem15.journal::lang.journal.rss_description_comment
type: textarea
size: small
tab: golem15.journal::lang.journal.tab_rss
rss_language:
span: left
label: golem15.journal::lang.journal.rss_language_label
comment: golem15.journal::lang.journal.rss_language_comment
type: text
default: en-us
tab: golem15.journal::lang.journal.tab_rss
rss_copyright:
span: right
label: golem15.journal::lang.journal.rss_copyright_label
comment: golem15.journal::lang.journal.rss_copyright_comment
type: text
tab: golem15.journal::lang.journal.tab_rss
rss_image_url:
span: left
label: golem15.journal::lang.journal.rss_image_url_label
comment: golem15.journal::lang.journal.rss_image_url_comment
type: text
tab: golem15.journal::lang.journal.tab_rss
rss_posts_per_feed:
span: right
label: golem15.journal::lang.journal.rss_posts_per_feed_label
comment: golem15.journal::lang.journal.rss_posts_per_feed_comment
type: number
default: 20
tab: golem15.journal::lang.journal.tab_rss

View File

@@ -21,6 +21,13 @@ func (Tag) Fillable() []string {
return []string{"name", "slug", "description"}
}
func (Tag) Rules() map[string]string {
return map[string]string{
"name": "required",
"slug": "required|between:3,64",
}
}
func init() {
Register(Tag{})
}

7
models/tag/columns.yaml Normal file
View File

@@ -0,0 +1,7 @@
columns:
name:
label: golem15.journal::lang.tag.name
searchable: true
slug:
label: golem15.journal::lang.tag.slug
searchable: true

19
models/tag/fields.yaml Normal file
View File

@@ -0,0 +1,19 @@
fields:
name:
label: golem15.journal::lang.tag.name
type: text
span: left
required: true
slug:
label: golem15.journal::lang.tag.slug
type: text
span: right
required: true
preset:
field: name
type: slug
description:
label: golem15.journal::lang.tag.description
type: textarea
size: large
span: full