diff --git a/AGENTS.md b/AGENTS.md index a36e0a9..1511432 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,6 @@ func TestFunction(t *testing.T) { ### Vite plugin tests -- `tests/feature/vite_plugin_test.go` runs the published `goravel-vite-plugin` against a real `vite build` and `npm run dev`. It runs `npm ci` itself when `node_modules/goravel-vite-plugin` is not installed, so the first run needs the network. +- `tests/feature/vite_plugin_test.go` runs `goravel-vite-plugin` against a real `vite build` and `npm run dev`. For now `package.json` links the local checkout (`file:../vite-plugin`, branch `modular`, built with `npm run build`), since no release builds module entries yet. It runs `npm ci` itself when `node_modules/goravel-vite-plugin` is not installed. - The suite builds into `public/build` and owns `public/hot` while it runs; it removes the build and puts back the hot file a dev server had written. - `vite.build_directory` and `vite.hot_file` in `config/vite.go` are paths relative to `public` (`build`, `hot`); a fixture hot file is a path under `public` too, never absolute. diff --git a/app/facades/modular.go b/app/facades/modular.go new file mode 100644 index 0000000..d048afc --- /dev/null +++ b/app/facades/modular.go @@ -0,0 +1,9 @@ +package facades + +import ( + "github.com/goravel/framework/contracts/modular" +) + +func Modular() modular.Registry { + return App().MakeModular() +} diff --git a/bootstrap/app.go b/bootstrap/app.go index a529ebc..9dd7473 100644 --- a/bootstrap/app.go +++ b/bootstrap/app.go @@ -43,6 +43,7 @@ var BootstrapRunners = func() []contractsfoundation.Runner { func Boot() contractsfoundation.Application { return foundation.Setup(). + WithModules(Modules). WithMigrations(Migrations). WithSeeders(Seeders). WithRouting(func() { diff --git a/bootstrap/modules.go b/bootstrap/modules.go new file mode 100755 index 0000000..ff1b323 --- /dev/null +++ b/bootstrap/modules.go @@ -0,0 +1,15 @@ +package bootstrap + +import ( + "github.com/goravel/framework/contracts/modular" + + "goravel/modules/admin" + "goravel/modules/blog" +) + +func Modules() []modular.Module { + return []modular.Module{ + &admin.Module{}, + &blog.Module{}, + } +} diff --git a/config/modular.go b/config/modular.go new file mode 100755 index 0000000..24a5310 --- /dev/null +++ b/config/modular.go @@ -0,0 +1,21 @@ +package config + +import ( + "github.com/goravel/framework/contracts/http" + + "goravel/app/facades" +) + +func init() { + config := facades.Config() + config.Add("modular", map[string]any{ + // Directory of the application's own modules, relative to the project root. + "path": "modules", + + // Route groups wrapping what modules register with Routes() and ApiRoutes(). + "routes": map[string]any{ + "web": map[string]any{"prefix": "", "middleware": []http.Middleware{}}, + "api": map[string]any{"prefix": "api", "middleware": []http.Middleware{}}, + }, + }) +} diff --git a/goravel b/goravel index 378c384..d71b13d 100644 Binary files a/goravel and b/goravel differ diff --git a/main.go b/main.go index add2bdb..0530b2e 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,8 @@ import ( "goravel/bootstrap" ) +//go:generate go run . artisan modular:publish + func main() { app := bootstrap.Boot() diff --git a/modules/.gitkeep b/modules/.gitkeep new file mode 100755 index 0000000..e69de29 diff --git a/modules/admin/contracts/dashboard.go b/modules/admin/contracts/dashboard.go new file mode 100644 index 0000000..fc799f8 --- /dev/null +++ b/modules/admin/contracts/dashboard.go @@ -0,0 +1,10 @@ +// Package contracts holds the capabilities other modules implement to take part +// in the admin. Go interfaces are structural, so implementing one needs no import. +package contracts + +// ProvidesDashboardScripts lists the Vite entries the admin loads before it +// mounts, relative to the module directory, e.g. "resources/js/dashboard.ts". +// Each one must also be in the module's ViteEntries(), which is what builds it. +type ProvidesDashboardScripts interface { + DashboardScripts() []string +} diff --git a/modules/admin/dashboard/entries.go b/modules/admin/dashboard/entries.go new file mode 100644 index 0000000..14d03bd --- /dev/null +++ b/modules/admin/dashboard/entries.go @@ -0,0 +1,59 @@ +// Package dashboard decides which Vite entries make up the admin page. +package dashboard + +import ( + "fmt" + "path" + "path/filepath" + "slices" + + "github.com/goravel/framework/contracts/modular" + + "goravel/modules/admin/contracts" +) + +// Name is the admin module's name, Main its own entry, which mounts the admin. +const ( + Name = "admin" + Main = "resources/js/main.ts" +) + +// Entries returns the project-relative Vite entries of the admin page: every +// module's dashboard scripts in module order, then the admin's Main, so all +// modules have registered by the time the admin mounts. +func Entries(registry modular.Registry) ([]string, error) { + var entries []string + for _, module := range registry.All() { + provider, ok := module.(contracts.ProvidesDashboardScripts) + if !ok { + continue + } + + var declared []string + if vite, ok := module.(modular.ProvidesViteEntries); ok { + declared = vite.ViteEntries() + } + + for _, script := range provider.DashboardScripts() { + if !slices.Contains(declared, script) { + return nil, fmt.Errorf("dashboard script [%s] of module [%s] is not in its ViteEntries()", script, module.Name()) + } + + entries = append(entries, entry(registry, module.Name(), script)) + } + } + + if !registry.Has(Name) { + return nil, fmt.Errorf("module [%s] is not registered", Name) + } + + return append(entries, entry(registry, Name, Main)), nil +} + +// entry joins a module-relative file onto the module directory, in the slash +// form Vite's manifest uses. +func entry(registry modular.Registry, name, file string) string { + dir, _ := registry.Path(name) + + return path.Join(filepath.ToSlash(dir), file) +} diff --git a/modules/admin/http/controllers/dashboard_controller.go b/modules/admin/http/controllers/dashboard_controller.go new file mode 100644 index 0000000..f3d842a --- /dev/null +++ b/modules/admin/http/controllers/dashboard_controller.go @@ -0,0 +1,51 @@ +package controllers + +import ( + "encoding/json" + "strings" + + "github.com/goravel/framework/contracts/http" + + "goravel/app/facades" + "goravel/modules/admin/dashboard" +) + +type DashboardController struct{} + +func NewDashboardController() *DashboardController { + return &DashboardController{} +} + +// Show renders the page the admin mounts in, with every dashboard script in one +// Vite call so shared chunks are preloaded once. +func (r *DashboardController) Show(ctx http.Context) http.Response { + entries, err := dashboard.Entries(facades.Modular()) + if err != nil { + return r.fail(ctx, err) + } + + tags, err := facades.Vite().Tags(entries...) + if err != nil { + return r.fail(ctx, err) + } + + boot, err := json.Marshal(map[string]any{ + "appName": facades.Config().GetString("app.name"), + "basePath": "/" + strings.Trim(facades.Config().GetString("modules.admin.path", "admin"), "/"), + }) + if err != nil { + return r.fail(ctx, err) + } + + return ctx.Response().View().Make("admin/dashboard.tmpl", map[string]any{ + "title": facades.Lang(ctx).Get("admin/messages.title"), + "tags": tags, + "boot": string(boot), + }) +} + +func (r *DashboardController) fail(ctx http.Context, err error) http.Response { + facades.Log().Error(err) + + return ctx.Response().String(http.StatusInternalServerError, err.Error()) +} diff --git a/modules/admin/lang/en/messages.json b/modules/admin/lang/en/messages.json new file mode 100644 index 0000000..856ad81 --- /dev/null +++ b/modules/admin/lang/en/messages.json @@ -0,0 +1,3 @@ +{ + "title": "Admin" +} diff --git a/modules/admin/module.go b/modules/admin/module.go new file mode 100644 index 0000000..4c5ddef --- /dev/null +++ b/modules/admin/module.go @@ -0,0 +1,59 @@ +package admin + +import ( + "embed" + "io/fs" + + "github.com/goravel/framework/contracts/route" + + "goravel/modules/admin/dashboard" + "goravel/modules/admin/routes" +) + +//go:embed resources/views +var views embed.FS + +//go:embed lang +var lang embed.FS + +// Module is a Vue admin that other modules fill: they register pages and +// dashboard widgets from a dashboard script (contracts.ProvidesDashboardScripts). +type Module struct{} + +func (m *Module) Name() string { + return dashboard.Name +} + +// Order -1 makes the admin the last module, so its routes come after the others'. +func (m *Module) Order() int { + return -1 +} + +// Config is available as "modules.admin": path is where the admin answers. +func (m *Module) Config() map[string]any { + return map[string]any{ + "path": "admin", + } +} + +func (m *Module) Routes(router route.Router) { + routes.Web(router) +} + +// Views are named "admin/". The application already has views under +// resources/views/admin, so this module's page is "admin/dashboard.tmpl". +func (m *Module) Views() fs.FS { + sub, _ := fs.Sub(views, "resources/views") + + return sub +} + +func (m *Module) Translations() fs.FS { + sub, _ := fs.Sub(lang, "lang") + + return sub +} + +func (m *Module) ViteEntries() []string { + return []string{dashboard.Main} +} diff --git a/modules/admin/resources/js/admin.css b/modules/admin/resources/js/admin.css new file mode 100644 index 0000000..b81ec8a --- /dev/null +++ b/modules/admin/resources/js/admin.css @@ -0,0 +1,33 @@ +:root { + --admin-bg: #f6f7f9; + --admin-surface: #fff; + --admin-border: #e3e6ea; + --admin-text: #1d2433; + --admin-muted: #687284; + --admin-accent: #3b5bdb; + font-family: system-ui, sans-serif; + color: var(--admin-text); + background: var(--admin-bg); +} + +body { margin: 0; } +.admin { display: flex; min-height: 100vh; } +.admin-sidebar { width: 220px; padding: 20px 12px; background: var(--admin-surface); border-right: 1px solid var(--admin-border); } +.admin-brand { display: block; padding: 0 8px 16px; } +.admin-sidebar h2 { margin: 16px 8px 4px; font-size: 12px; text-transform: uppercase; color: var(--admin-muted); } +.admin-sidebar a { display: block; padding: 6px 8px; border-radius: 6px; color: inherit; text-decoration: none; } +.admin-sidebar a.active { background: var(--admin-bg); color: var(--admin-accent); } +.admin-main { flex: 1; padding: 20px 32px; } +.admin-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; } +.admin-header h1 { margin: 0; font-size: 22px; } +.admin-header-actions { display: flex; gap: 8px; } +.admin-widgets { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; } +.admin-card { padding: 16px; background: var(--admin-surface); border: 1px solid var(--admin-border); border-radius: 8px; } +.admin-card h3 { margin: 0 0 4px; font-size: 13px; font-weight: 500; color: var(--admin-muted); } +.admin-stat { font-size: 30px; font-weight: 600; } +.admin-button { display: inline-block; padding: 7px 14px; border: 0; border-radius: 6px; background: var(--admin-accent); color: #fff; font: inherit; text-decoration: none; cursor: pointer; } +.admin-table { width: 100%; border-collapse: collapse; background: var(--admin-surface); } +.admin-table th, .admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--admin-border); text-align: left; } +.admin-form { display: grid; gap: 12px; max-width: 520px; } +.admin-form input, .admin-form textarea { padding: 8px; border: 1px solid var(--admin-border); border-radius: 6px; font: inherit; } +.admin-error { color: #c92a2a; font-size: 13px; } diff --git a/modules/admin/resources/js/index.ts b/modules/admin/resources/js/index.ts new file mode 100644 index 0000000..af04937 --- /dev/null +++ b/modules/admin/resources/js/index.ts @@ -0,0 +1,17 @@ +// What a dashboard script imports as '@admin/index'. Its first import is the +// admin's own registration, so the dashboard comes before every module. +import DashboardPage from './layout/DashboardPage.vue' +import { registerModule } from './lib/registry' + +registerModule({ + id: 'dashboard', + menu: (menu) => [{ label: 'General', items: [{ label: 'Dashboard', path: '/' }] }, ...menu], + routes: [{ path: '/', component: DashboardPage }], +}) + +export { default as AdminLink } from './layout/AdminLink.vue' +export { DashboardWidgets, HeaderActions } from './layout/slots' +export { basePath } from './lib/boot' +export { registerModule, type AdminRoute, type MenuFilter, type MenuGroup, type MenuItem, type ModuleRegistration } from './lib/registry' +export { navigate } from './lib/router' +export { createSlot, type Slot } from './lib/slot' diff --git a/modules/admin/resources/js/layout/AdminLayout.vue b/modules/admin/resources/js/layout/AdminLayout.vue new file mode 100644 index 0000000..61cade7 --- /dev/null +++ b/modules/admin/resources/js/layout/AdminLayout.vue @@ -0,0 +1,50 @@ + + + diff --git a/modules/admin/resources/js/layout/AdminLink.vue b/modules/admin/resources/js/layout/AdminLink.vue new file mode 100644 index 0000000..29f13fc --- /dev/null +++ b/modules/admin/resources/js/layout/AdminLink.vue @@ -0,0 +1,17 @@ + + + diff --git a/modules/admin/resources/js/layout/DashboardPage.vue b/modules/admin/resources/js/layout/DashboardPage.vue new file mode 100644 index 0000000..b866c0f --- /dev/null +++ b/modules/admin/resources/js/layout/DashboardPage.vue @@ -0,0 +1,7 @@ + + + diff --git a/modules/admin/resources/js/layout/slots.ts b/modules/admin/resources/js/layout/slots.ts new file mode 100644 index 0000000..987bbba --- /dev/null +++ b/modules/admin/resources/js/layout/slots.ts @@ -0,0 +1,7 @@ +import { createSlot } from '../lib/slot' + +/** The dashboard's widget grid. Modules fill it from the `fills` of their registration, in module order. */ +export const DashboardWidgets = createSlot('dashboard-widgets') + +/** The top right of the header. A page renders for as long as it is shown. */ +export const HeaderActions = createSlot('header-actions') diff --git a/modules/admin/resources/js/lib/boot.ts b/modules/admin/resources/js/lib/boot.ts new file mode 100644 index 0000000..49c2689 --- /dev/null +++ b/modules/admin/resources/js/lib/boot.ts @@ -0,0 +1,14 @@ +/** What the server hands the admin, from the data-boot attribute of #admin. */ +interface Boot { + appName: string + basePath: string +} + +function read(): Boot { + const boot = document.getElementById('admin')?.dataset.boot + if (!boot) throw new Error('[admin] #admin[data-boot] not found: the admin only runs on its own page') + + return JSON.parse(boot) as Boot +} + +export const { appName, basePath } = read() diff --git a/modules/admin/resources/js/lib/registry.ts b/modules/admin/resources/js/lib/registry.ts new file mode 100644 index 0000000..048ea4d --- /dev/null +++ b/modules/admin/resources/js/lib/registry.ts @@ -0,0 +1,70 @@ +import type { Component } from 'vue' + +export interface MenuItem { + label: string + /** Relative to the admin's base path, starting with "/". */ + path: string +} + +export interface MenuGroup { + label: string + items: MenuItem[] +} + +/** Receives the menu the modules before it built and returns the one it wants. */ +export type MenuFilter = (menu: MenuGroup[]) => MenuGroup[] + +export interface AdminRoute { + /** Relative to the admin's base path; ":name" segments become props. */ + path: string + component: Component +} + +export interface ModuleRegistration { + id: string + /** Non-negative counts from the start, negative from the end (-1 = last). Default 0. */ + order?: number + menu?: MenuFilter + routes?: AdminRoute[] + /** Rendered for as long as the admin is, on every page: where a module fills slots such as DashboardWidgets. */ + fills?: Component +} + +const registrations: ModuleRegistration[] = [] +let sealed = false + +export function registerModule(registration: ModuleRegistration): void { + if (sealed) { + throw new Error(`[admin] module "${registration.id}" registered after the admin mounted: call registerModule() at the top level of a dashboard script`) + } + if (registrations.some((module) => module.id === registration.id)) { + throw new Error(`[admin] module "${registration.id}" is already registered`) + } + + registrations.push(registration) +} + +/** Closes the registry; main.ts calls it right before mounting. */ +export function seal(): void { + sealed = true +} + +/** Same rule as modular.Ordered on the Go side: non-negative ascending, then negative ascending, ties keep registration order. */ +export function modules(): ModuleRegistration[] { + const rank = (order: number) => (order < 0 ? [1, order] : [0, order]) + + return [...registrations].sort((a, b) => { + const [ga, oa] = rank(a.order ?? 0) + const [gb, ob] = rank(b.order ?? 0) + + return ga - gb || oa - ob + }) +} + +export function menu(): MenuGroup[] { + return modules().reduce((groups, module) => (module.menu ? module.menu(groups) : groups), []) +} + +export function routes(): AdminRoute[] { + return modules().flatMap((module) => module.routes ?? []) +} diff --git a/modules/admin/resources/js/lib/router.ts b/modules/admin/resources/js/lib/router.ts new file mode 100644 index 0000000..69afe47 --- /dev/null +++ b/modules/admin/resources/js/lib/router.ts @@ -0,0 +1,59 @@ +import { ref } from 'vue' + +import { basePath } from './boot' +import type { AdminRoute, MenuGroup } from './registry' + +/** The path below the admin's base path: "/admin/blog" is "/blog". */ +function relative(pathname: string): string { + const rest = pathname === basePath ? '' : pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : '' + + return rest.replace(/\/+$/, '') || '/' +} + +export const currentPath = ref(relative(window.location.pathname)) + +window.addEventListener('popstate', () => { + currentPath.value = relative(window.location.pathname) +}) + +export function href(path: string): string { + return path === '/' ? basePath : basePath + path +} + +export function navigate(path: string): void { + window.history.pushState(null, '', href(path)) + currentPath.value = path +} + +export function match(routes: AdminRoute[], path: string): { route: AdminRoute; params: Record } | null { + const segments = path.split('/').filter(Boolean) + + for (const route of routes) { + const pattern = route.path.split('/').filter(Boolean) + if (pattern.length !== segments.length) continue + + const params: Record = {} + const ok = pattern.every((part, i) => { + if (part.startsWith(':')) { + params[part.slice(1)] = decodeURIComponent(segments[i]) + return true + } + + return part === segments[i] + }) + if (ok) return { route, params } + } + + return null +} + +/** The deepest menu item covering the page, so "/blog/new" lights up "New post", not "Posts". */ +export function activePath(path: string, groups: MenuGroup[]): string | null { + let active: string | null = null + for (const item of groups.flatMap((group) => group.items)) { + const covers = item.path === '/' ? path === '/' : path === item.path || path.startsWith(`${item.path}/`) + if (covers && (!active || item.path.length > active.length)) active = item.path + } + + return active +} diff --git a/modules/admin/resources/js/lib/slot.ts b/modules/admin/resources/js/lib/slot.ts new file mode 100644 index 0000000..df2af5a --- /dev/null +++ b/modules/admin/resources/js/lib/slot.ts @@ -0,0 +1,54 @@ +import { defineComponent, h, inject, provide, shallowRef, Teleport, type Component, type InjectionKey, type ShallowRef } from 'vue' + +export interface Slot { + /** Wraps both the outlet and every fill. */ + Provider: Component + /** The spot the layout owns; fills are teleported into it in mount order. */ + Outlet: Component + /** Places its children into the outlet while both are mounted; renders nothing otherwise. */ + Fill: Component +} + +export function createSlot(name: string): Slot { + const key: InjectionKey> = Symbol(name) + + function useTarget(): ShallowRef { + const target = inject(key, null) + if (!target) throw new Error(`[slot] "${name}" used outside its provider`) + + return target + } + + return { + Provider: defineComponent({ + name: `${name}-provider`, + setup(_, { slots }) { + provide(key, shallowRef(null)) + + return () => slots.default?.() + }, + }), + + Outlet: defineComponent({ + name: `${name}-outlet`, + setup() { + const target = useTarget() + // One function for the component's life, so Vue calls it on mount and unmount only. + const setTarget = (element: unknown) => { + target.value = element as HTMLElement | null + } + + return () => h('div', { ref: setTarget, 'data-slot': name }) + }, + }), + + Fill: defineComponent({ + name: `${name}-fill`, + setup(_, { slots }) { + const target = useTarget() + + return () => (target.value ? h(Teleport, { to: target.value }, slots.default?.() ?? []) : null) + }, + }), + } +} diff --git a/modules/admin/resources/js/main.ts b/modules/admin/resources/js/main.ts new file mode 100644 index 0000000..0be2edf --- /dev/null +++ b/modules/admin/resources/js/main.ts @@ -0,0 +1,12 @@ +// The admin's own entry. The page loads every module's dashboard script before +// this one, so all modules have registered by now. +import { createApp } from 'vue' + +import './index' +import './admin.css' +import AdminLayout from './layout/AdminLayout.vue' +import { seal } from './lib/registry' + +seal() + +createApp(AdminLayout).mount('#admin') diff --git a/modules/admin/resources/views/dashboard.tmpl b/modules/admin/resources/views/dashboard.tmpl new file mode 100644 index 0000000..5946f48 --- /dev/null +++ b/modules/admin/resources/views/dashboard.tmpl @@ -0,0 +1,13 @@ + + + + + + {{ .title }} + {{/* Module dashboard scripts first, the admin's main.ts last. */}} + {{ .tags }} + + +
+ + diff --git a/modules/admin/routes/web.go b/modules/admin/routes/web.go new file mode 100644 index 0000000..04fc2b5 --- /dev/null +++ b/modules/admin/routes/web.go @@ -0,0 +1,21 @@ +package routes + +import ( + "strings" + + "github.com/goravel/framework/contracts/route" + + "goravel/app/facades" + "goravel/modules/admin/http/controllers" +) + +// Web serves the admin page at modules.admin.path and every URL beneath it: the +// admin's own router decides which page is shown, so a refresh on +// /admin/blog/posts works. +func Web(router route.Router) { + base := "/" + strings.Trim(facades.Config().GetString("modules.admin.path", "admin"), "/") + controller := controllers.NewDashboardController() + + router.Get(base, controller.Show) + router.Get(base+"/*page", controller.Show) +} diff --git a/modules/blog/database/migrations/20261114055445_create_blog_posts_table.go b/modules/blog/database/migrations/20261114055445_create_blog_posts_table.go new file mode 100644 index 0000000..8c90f38 --- /dev/null +++ b/modules/blog/database/migrations/20261114055445_create_blog_posts_table.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "github.com/goravel/framework/contracts/database/schema" + + "goravel/app/facades" +) + +type M20261114055445CreateBlogPostsTable struct{} + +// Signature The unique signature for the migration. +func (r *M20261114055445CreateBlogPostsTable) Signature() string { + return "20261114055445_create_blog_posts_table" +} + +// Up Run the migrations. +func (r *M20261114055445CreateBlogPostsTable) Up() error { + if !facades.Schema().HasTable("blog_posts") { + return facades.Schema().Create("blog_posts", func(table schema.Blueprint) { + table.ID() + table.String("title") + table.Text("body") + table.TimestampsTz() + }) + } + + return nil +} + +// Down Reverse the migrations. +func (r *M20261114055445CreateBlogPostsTable) Down() error { + return facades.Schema().DropIfExists("blog_posts") +} diff --git a/modules/blog/http/controllers/blog_controller.go b/modules/blog/http/controllers/blog_controller.go new file mode 100644 index 0000000..31af8df --- /dev/null +++ b/modules/blog/http/controllers/blog_controller.go @@ -0,0 +1,27 @@ +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + + "goravel/app/facades" + "goravel/modules/blog/models" +) + +type BlogController struct{} + +func NewBlogController() *BlogController { + return &BlogController{} +} + +// Index is the public blog page: a module view with a module translation. +func (r *BlogController) Index(ctx http.Context) http.Response { + var posts []models.Post + if err := facades.Orm().Query().Order("id desc").Limit(facades.Config().GetInt("modules.blog.per_page")).Find(&posts); err != nil { + return ctx.Response().String(http.StatusInternalServerError, err.Error()) + } + + return ctx.Response().View().Make("blog/index.tmpl", map[string]any{ + "title": facades.Lang(ctx).Get("blog/messages.title"), + "posts": posts, + }) +} diff --git a/modules/blog/http/controllers/post_controller.go b/modules/blog/http/controllers/post_controller.go new file mode 100644 index 0000000..be764dc --- /dev/null +++ b/modules/blog/http/controllers/post_controller.go @@ -0,0 +1,55 @@ +package controllers + +import ( + "github.com/goravel/framework/contracts/http" + + "goravel/app/facades" + "goravel/modules/blog/models" +) + +type PostController struct{} + +func NewPostController() *PostController { + return &PostController{} +} + +// Index returns the latest posts and the total, which the dashboard card shows. +func (r *PostController) Index(ctx http.Context) http.Response { + var posts []models.Post + if err := facades.Orm().Query().Order("id desc").Limit(facades.Config().GetInt("modules.blog.per_page")).Find(&posts); err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"message": err.Error()}) + } + + total, err := facades.Orm().Query().Model(&models.Post{}).Count() + if err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"message": err.Error()}) + } + + return ctx.Response().Success().Json(http.Json{ + "data": posts, + "total": total, + }) +} + +func (r *PostController) Store(ctx http.Context) http.Response { + validator, err := ctx.Request().Validate(map[string]any{ + "title": "required|max_len:255", + "body": "required", + }) + if err != nil { + return ctx.Response().Json(http.StatusBadRequest, http.Json{"message": err.Error()}) + } + if validator.Fails() { + return ctx.Response().Json(http.StatusUnprocessableEntity, http.Json{"errors": validator.Errors().All()}) + } + + post := models.Post{ + Title: ctx.Request().Input("title"), + Body: ctx.Request().Input("body"), + } + if err := facades.Orm().Query().Create(&post); err != nil { + return ctx.Response().Json(http.StatusInternalServerError, http.Json{"message": err.Error()}) + } + + return ctx.Response().Json(http.StatusCreated, http.Json{"data": post}) +} diff --git a/modules/blog/lang/en/messages.json b/modules/blog/lang/en/messages.json new file mode 100644 index 0000000..169d064 --- /dev/null +++ b/modules/blog/lang/en/messages.json @@ -0,0 +1,3 @@ +{ + "title": "Blog" +} diff --git a/modules/blog/models/post.go b/modules/blog/models/post.go new file mode 100644 index 0000000..36a7374 --- /dev/null +++ b/modules/blog/models/post.go @@ -0,0 +1,16 @@ +package models + +import ( + "github.com/goravel/framework/database/orm" +) + +type Post struct { + orm.Model + Title string `json:"title"` + Body string `json:"body"` +} + +// TableName is prefixed with the module name so modules cannot collide. +func (r *Post) TableName() string { + return "blog_posts" +} diff --git a/modules/blog/module.go b/modules/blog/module.go new file mode 100644 index 0000000..7ea5eea --- /dev/null +++ b/modules/blog/module.go @@ -0,0 +1,75 @@ +package blog + +import ( + "embed" + "io/fs" + + "github.com/goravel/framework/contracts/database/schema" + "github.com/goravel/framework/contracts/route" + + admincontracts "goravel/modules/admin/contracts" + "goravel/modules/blog/database/migrations" + "goravel/modules/blog/routes" +) + +//go:embed resources/views +var views embed.FS + +//go:embed lang +var lang embed.FS + +// dashboardScript registers the blog's admin pages and its stat card. +const dashboardScript = "resources/js/dashboard.ts" + +// The admin finds the blog by this interface; the assertion only documents it. +var _ admincontracts.ProvidesDashboardScripts = (*Module)(nil) + +type Module struct{} + +func (m *Module) Name() string { + return "blog" +} + +func (m *Module) Routes(router route.Router) { + routes.Web(router) +} + +func (m *Module) ApiRoutes(router route.Router) { + routes.Api(router) +} + +// Config is available as "modules.blog". +func (m *Module) Config() map[string]any { + return map[string]any{ + "per_page": 10, + } +} + +func (m *Module) Migrations() []schema.Migration { + return []schema.Migration{ + &migrations.M20261114055445CreateBlogPostsTable{}, + } +} + +// Views are named "blog/", e.g. "blog/index.tmpl". +func (m *Module) Views() fs.FS { + sub, _ := fs.Sub(views, "resources/views") + + return sub +} + +// Translations are grouped as "blog/", e.g. "blog/messages.title". +func (m *Module) Translations() fs.FS { + sub, _ := fs.Sub(lang, "lang") + + return sub +} + +// ViteEntries builds the dashboard script; DashboardScripts has the admin load it. +func (m *Module) ViteEntries() []string { + return []string{dashboardScript} +} + +func (m *Module) DashboardScripts() []string { + return []string{dashboardScript} +} diff --git a/modules/blog/resources/js/api.ts b/modules/blog/resources/js/api.ts new file mode 100644 index 0000000..8265609 --- /dev/null +++ b/modules/blog/resources/js/api.ts @@ -0,0 +1,28 @@ +export interface Post { + id: number + title: string + body: string + created_at: string +} + +export type ValidationErrors = Record> + +export async function listPosts(): Promise<{ data: Post[]; total: number }> { + const response = await fetch('/api/blog/posts', { headers: { Accept: 'application/json' } }) + if (!response.ok) throw new Error(`GET /api/blog/posts: ${response.status}`) + + return response.json() +} + +/** Resolves to the validation errors when the server refuses the post, null when it is created. */ +export async function createPost(input: { title: string; body: string }): Promise { + const response = await fetch('/api/blog/posts', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + if (response.status === 422) return (await response.json()).errors + if (!response.ok) throw new Error(`POST /api/blog/posts: ${response.status}`) + + return null +} diff --git a/modules/blog/resources/js/components/BlogFills.vue b/modules/blog/resources/js/components/BlogFills.vue new file mode 100644 index 0000000..ddd1471 --- /dev/null +++ b/modules/blog/resources/js/components/BlogFills.vue @@ -0,0 +1,11 @@ + + + diff --git a/modules/blog/resources/js/components/NewPostPage.vue b/modules/blog/resources/js/components/NewPostPage.vue new file mode 100644 index 0000000..2910f9f --- /dev/null +++ b/modules/blog/resources/js/components/NewPostPage.vue @@ -0,0 +1,38 @@ + + +