From afd0a2cafcc62fcfb13359530147ae798349cfa1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 18:42:29 +0200 Subject: [PATCH] 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 --- admin.go | 18 +++++ admin_navigation.go | 18 ++++- controllers/admin_registry.go | 12 ++++ controllers/categories.go | 48 +++++++++++++ controllers/categories/config_form.yaml | 19 +++++ controllers/categories/config_list.yaml | 19 +++++ controllers/posts.go | 19 +++++ controllers/tags.go | 36 ++++++++++ controllers/tags/config_form.yaml | 19 +++++ controllers/tags/config_list.yaml | 19 +++++ models/category.go | 7 ++ models/category/columns.yaml | 7 ++ models/category/fields.yaml | 21 ++++++ models/settings/fields.yaml | 96 +++++++++++++++++++++++++ models/tag.go | 7 ++ models/tag/columns.yaml | 7 ++ models/tag/fields.yaml | 19 +++++ 17 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 controllers/categories.go create mode 100644 controllers/categories/config_form.yaml create mode 100644 controllers/categories/config_list.yaml create mode 100644 controllers/tags.go create mode 100644 controllers/tags/config_form.yaml create mode 100644 controllers/tags/config_list.yaml create mode 100644 models/category/columns.yaml create mode 100644 models/category/fields.yaml create mode 100644 models/settings/fields.yaml create mode 100644 models/tag/columns.yaml create mode 100644 models/tag/fields.yaml diff --git a/admin.go b/admin.go index d2297cd..688bd48 100644 --- a/admin.go +++ b/admin.go @@ -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) ) diff --git a/admin_navigation.go b/admin_navigation.go index b0e7147..27ccb78 100644 --- a/admin_navigation.go +++ b/admin_navigation.go @@ -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, + }, }, }, } diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 42c6e46..2213143 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -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{} } diff --git a/controllers/categories.go b/controllers/categories.go new file mode 100644 index 0000000..caaa237 --- /dev/null +++ b/controllers/categories.go @@ -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{} +) diff --git a/controllers/categories/config_form.yaml b/controllers/categories/config_form.yaml new file mode 100644 index 0000000..4afe73f --- /dev/null +++ b/controllers/categories/config_form.yaml @@ -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 diff --git a/controllers/categories/config_list.yaml b/controllers/categories/config_list.yaml new file mode 100644 index 0000000..a724646 --- /dev/null +++ b/controllers/categories/config_list.yaml @@ -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 diff --git a/controllers/posts.go b/controllers/posts.go index 01d5642..39a454b 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -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 diff --git a/controllers/tags.go b/controllers/tags.go new file mode 100644 index 0000000..1e3b6a3 --- /dev/null +++ b/controllers/tags.go @@ -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{} +) diff --git a/controllers/tags/config_form.yaml b/controllers/tags/config_form.yaml new file mode 100644 index 0000000..a0a57eb --- /dev/null +++ b/controllers/tags/config_form.yaml @@ -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 diff --git a/controllers/tags/config_list.yaml b/controllers/tags/config_list.yaml new file mode 100644 index 0000000..9e8fe0c --- /dev/null +++ b/controllers/tags/config_list.yaml @@ -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 diff --git a/models/category.go b/models/category.go index 6da9849..d8e7f19 100644 --- a/models/category.go +++ b/models/category.go @@ -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{}) } diff --git a/models/category/columns.yaml b/models/category/columns.yaml new file mode 100644 index 0000000..cee5829 --- /dev/null +++ b/models/category/columns.yaml @@ -0,0 +1,7 @@ +columns: + name: + label: golem15.journal::lang.category.name + searchable: true + slug: + label: golem15.journal::lang.category.slug + searchable: true diff --git a/models/category/fields.yaml b/models/category/fields.yaml new file mode 100644 index 0000000..e52fa82 --- /dev/null +++ b/models/category/fields.yaml @@ -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 diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml new file mode 100644 index 0000000..1197c2b --- /dev/null +++ b/models/settings/fields.yaml @@ -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 diff --git a/models/tag.go b/models/tag.go index 343e589..027238c 100644 --- a/models/tag.go +++ b/models/tag.go @@ -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{}) } diff --git a/models/tag/columns.yaml b/models/tag/columns.yaml new file mode 100644 index 0000000..1d04e59 --- /dev/null +++ b/models/tag/columns.yaml @@ -0,0 +1,7 @@ +columns: + name: + label: golem15.journal::lang.tag.name + searchable: true + slug: + label: golem15.journal::lang.tag.slug + searchable: true diff --git a/models/tag/fields.yaml b/models/tag/fields.yaml new file mode 100644 index 0000000..19ccfa0 --- /dev/null +++ b/models/tag/fields.yaml @@ -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