diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 4fa9a58..5f51337 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -148,11 +148,13 @@ Plans:
2. Dashboard displays widgets from plugins (reports, stats, quick actions)
3. Plugins can register settings pages accessible from admin menu
4. Settings use the same YAML-driven form system
-**Plans**: TBD
+**Plans**: 4 plans
Plans:
-- [ ] 08-01: Dashboard framework and widget system
-- [ ] 08-02: Plugin settings pages
+- [ ] 08-01-PLAN.md - Dashboard widget framework (DashboardWidget trait, WidgetRegistry, DashboardPreferenceService)
+- [ ] 08-02-PLAN.md - Plugin settings pages (SettingsManager, SettingsModel, system_settings table)
+- [ ] 08-03-PLAN.md - Dashboard container and core widgets (Gridstack.js, SSE updates, Welcome/Status/QuickActions)
+- [ ] 08-04-PLAN.md - Settings controller integration (SettingsController, SettingsIndex, Phase 7 form integration)
### Phase 9: Content Management
**Goal**: Manage CMS pages, layouts, media, and navigation
@@ -213,7 +215,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 5. CLI Scaffolding | 0/2 | Planned | - |
| 6. Backend Authentication | 0/3 | Planned | - |
| 7. Admin Forms & Lists | 0/3 | Planned | - |
-| 8. Admin Dashboard | 0/2 | Not started | - |
+| 8. Admin Dashboard | 0/4 | Planned | - |
| 9. Content Management | 0/5 | Not started | - |
| 10. Core Plugins | 0/4 | Not started | - |
diff --git a/.planning/phases/08-admin-dashboard/08-01-PLAN.md b/.planning/phases/08-admin-dashboard/08-01-PLAN.md
new file mode 100644
index 0000000..d7cc85e
--- /dev/null
+++ b/.planning/phases/08-admin-dashboard/08-01-PLAN.md
@@ -0,0 +1,437 @@
+---
+phase: 08-admin-dashboard
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - summercms/admin/src/dashboard/DashboardWidget.scala
+ - summercms/admin/src/dashboard/WidgetRegistry.scala
+ - summercms/admin/src/dashboard/DashboardPreference.scala
+ - summercms/admin/src/dashboard/DashboardPreferenceService.scala
+ - summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql
+autonomous: true
+
+must_haves:
+ truths:
+ - "Widget classes can be registered with metadata during plugin boot"
+ - "Dashboard layouts are stored per-user in database"
+ - "Default widget layout loads when user has no preferences"
+ artifacts:
+ - path: "summercms/admin/src/dashboard/DashboardWidget.scala"
+ provides: "Base trait and types for dashboard widgets"
+ contains: "trait DashboardWidget"
+ - path: "summercms/admin/src/dashboard/WidgetRegistry.scala"
+ provides: "Widget registration and lookup service"
+ exports: ["WidgetRegistry"]
+ - path: "summercms/admin/src/dashboard/DashboardPreferenceService.scala"
+ provides: "Per-user layout persistence"
+ exports: ["DashboardPreferenceService"]
+ - path: "summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql"
+ provides: "Dashboard preferences table"
+ contains: "CREATE TABLE dashboard_preferences"
+ key_links:
+ - from: "WidgetRegistry"
+ to: "DashboardWidget"
+ via: "registerWidget method accepts widget class"
+ pattern: "def registerWidget.*DashboardWidget"
+ - from: "DashboardPreferenceService"
+ to: "Quill"
+ via: "database queries"
+ pattern: "run\\(query"
+---
+
+
+Create the foundation for dashboard widgets: the base trait defining widget contracts, a registry service for collecting widgets from plugins, and a per-user preferences service for storing dashboard layouts.
+
+Purpose: Enable plugins to register dashboard widgets and persist user customizations
+Output: DashboardWidget trait, WidgetRegistry, DashboardPreferenceService, migration
+
+
+
+@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
+@/home/jin/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/08-admin-dashboard/08-RESEARCH.md
+
+# Phase 1 established Quill patterns
+@summercms/db/src/main/scala/summercms/db/QuillContext.scala
+
+
+
+
+
+ Task 1: Dashboard Widget Base Types
+
+ summercms/admin/src/dashboard/DashboardWidget.scala
+ summercms/admin/src/dashboard/WidgetRegistry.scala
+
+
+Create the dashboard widget system foundation:
+
+**DashboardWidget.scala** - Define the base trait and supporting types:
+
+```scala
+package summercms.admin.dashboard
+
+import scalatags.Text.all._
+import zio._
+import zio.stream._
+
+/** Property type for widget configuration */
+enum PropertyType:
+ case String, Number, Checkbox, Dropdown, DateRange
+
+/** Definition of a configurable widget property */
+case class PropertyDef(
+ title: String,
+ `type`: PropertyType,
+ description: Option[String] = None,
+ default: Option[String] = None,
+ options: Map[String, String] = Map.empty,
+ required: Boolean = false
+)
+
+/** Position and size of widget in Gridstack grid */
+case class WidgetPosition(
+ x: Int,
+ y: Int,
+ w: Int,
+ h: Int
+)
+
+/** Runtime configuration for a widget instance */
+case class WidgetConfig(
+ alias: String,
+ properties: Map[String, Any],
+ position: WidgetPosition
+)
+
+/** Update message for real-time widget refresh */
+case class WidgetUpdate(
+ widgetAlias: String,
+ content: Frag
+)
+
+/** Context passed to widget render method */
+case class DashboardContext(
+ userId: Long,
+ userName: String,
+ permissions: Set[String]
+)
+
+/** Base trait for all dashboard widgets */
+trait DashboardWidget:
+ /** Unique widget identifier (class name or custom) */
+ def widgetId: String
+
+ /** Default alias when adding widget */
+ def defaultAlias: String = widgetId
+
+ /** Widget display name (translation key) */
+ def name: String
+
+ /** Widget description (translation key) */
+ def description: String
+
+ /** Define configurable properties with defaults */
+ def defineProperties: Map[String, PropertyDef] = Map(
+ "title" -> PropertyDef(
+ title = "Widget Title",
+ `type` = PropertyType.String,
+ default = Some(name)
+ ),
+ "ocWidgetWidth" -> PropertyDef(
+ title = "Widget Width (columns)",
+ `type` = PropertyType.Dropdown,
+ options = (1 to 12).map(i => i.toString -> s"$i columns").toMap,
+ default = Some("6")
+ )
+ )
+
+ /** Default grid position and size */
+ def defaultPosition: WidgetPosition = WidgetPosition(0, 0, 6, 2)
+
+ /** Render widget content */
+ def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag]
+
+ /** Optional stream of updates for real-time widgets */
+ def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] = None
+```
+
+**WidgetRegistry.scala** - Service for collecting and resolving widgets:
+
+```scala
+package summercms.admin.dashboard
+
+import zio._
+
+/** Metadata about a registered widget */
+case class WidgetInfo(
+ name: String,
+ description: String,
+ context: List[String] = List("dashboard"),
+ permissions: List[String] = Nil
+)
+
+/** Registry for dashboard widgets */
+trait WidgetRegistry:
+ def registerWidget(widget: DashboardWidget, info: WidgetInfo): UIO[Unit]
+ def listWidgets: UIO[Map[String, (WidgetInfo, DashboardWidget)]]
+ def listWidgetsForContext(context: String): UIO[Map[String, (WidgetInfo, DashboardWidget)]]
+ def resolveWidget(widgetId: String): UIO[Option[DashboardWidget]]
+
+object WidgetRegistry:
+ val live: ZLayer[Any, Nothing, WidgetRegistry] =
+ ZLayer.fromZIO {
+ Ref.make(Map.empty[String, (WidgetInfo, DashboardWidget)]).map { widgetsRef =>
+ new WidgetRegistry:
+ def registerWidget(widget: DashboardWidget, info: WidgetInfo): UIO[Unit] =
+ widgetsRef.update(_ + (widget.widgetId -> (info, widget)))
+
+ def listWidgets: UIO[Map[String, (WidgetInfo, DashboardWidget)]] =
+ widgetsRef.get
+
+ def listWidgetsForContext(context: String): UIO[Map[String, (WidgetInfo, DashboardWidget)]] =
+ listWidgets.map(_.filter(_._2._1.context.contains(context)))
+
+ def resolveWidget(widgetId: String): UIO[Option[DashboardWidget]] =
+ widgetsRef.get.map(_.get(widgetId).map(_._2))
+ }
+ }
+
+ def registerWidget(widget: DashboardWidget, info: WidgetInfo): ZIO[WidgetRegistry, Nothing, Unit] =
+ ZIO.serviceWithZIO[WidgetRegistry](_.registerWidget(widget, info))
+
+ def listWidgets: ZIO[WidgetRegistry, Nothing, Map[String, (WidgetInfo, DashboardWidget)]] =
+ ZIO.serviceWithZIO[WidgetRegistry](_.listWidgets)
+
+ def resolveWidget(widgetId: String): ZIO[WidgetRegistry, Nothing, Option[DashboardWidget]] =
+ ZIO.serviceWithZIO[WidgetRegistry](_.resolveWidget(widgetId))
+```
+
+Use ZIO Ref for thread-safe mutable state. Widget IDs are the key for lookup.
+
+
+ mill summercms.admin.compile succeeds
+ Types DashboardWidget, WidgetRegistry, PropertyDef exist and compile
+
+
+ DashboardWidget trait defines render/updates/defineProperties contract
+ WidgetRegistry provides register/list/resolve operations with ZIO Ref
+ All supporting case classes (PropertyDef, WidgetConfig, etc.) defined
+
+
+
+
+ Task 2: Dashboard Preferences Service
+
+ summercms/admin/src/dashboard/DashboardPreference.scala
+ summercms/admin/src/dashboard/DashboardPreferenceService.scala
+ summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql
+
+
+Create the preferences persistence layer for per-user dashboard layouts:
+
+**V005__dashboard_preferences.sql** - Migration for preferences table:
+
+```sql
+-- Dashboard preferences per user
+CREATE TABLE dashboard_preferences (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ context VARCHAR(50) NOT NULL DEFAULT 'dashboard',
+ widgets JSONB NOT NULL DEFAULT '[]'::jsonb,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT uq_dashboard_prefs_user_context UNIQUE (user_id, context)
+);
+
+CREATE INDEX idx_dashboard_prefs_user ON dashboard_preferences(user_id);
+
+-- Trigger to update updated_at
+CREATE TRIGGER update_dashboard_prefs_updated_at
+ BEFORE UPDATE ON dashboard_preferences
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+```
+
+**DashboardPreference.scala** - Model and stored config types:
+
+```scala
+package summercms.admin.dashboard
+
+import io.circe._
+import io.circe.generic.semiauto._
+import java.time.OffsetDateTime
+
+/** Stored widget configuration in preferences JSON */
+case class StoredWidgetConfig(
+ widgetId: String,
+ alias: String,
+ sortOrder: Int,
+ configuration: Map[String, Json] // Using Json for flexibility
+)
+
+object StoredWidgetConfig:
+ given Encoder[StoredWidgetConfig] = deriveEncoder
+ given Decoder[StoredWidgetConfig] = deriveDecoder
+
+/** Database row for dashboard_preferences */
+case class DashboardPreference(
+ id: Long,
+ userId: Long,
+ context: String,
+ widgets: Json, // JSONB stored as circe Json
+ createdAt: OffsetDateTime,
+ updatedAt: OffsetDateTime
+)
+```
+
+**DashboardPreferenceService.scala** - Service for CRUD operations:
+
+```scala
+package summercms.admin.dashboard
+
+import io.circe._
+import io.circe.syntax._
+import io.circe.parser._
+import zio._
+import summercms.db.QuillContext
+
+trait DashboardPreferenceService:
+ def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]]
+ def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit]
+ def resetToDefault(userId: Long, context: String): Task[Unit]
+ def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]]
+
+object DashboardPreferenceService:
+ val live: ZLayer[QuillContext, Nothing, DashboardPreferenceService] =
+ ZLayer.fromZIO {
+ ZIO.service[QuillContext].map { quill =>
+ import quill.ctx._
+
+ new DashboardPreferenceService:
+ def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]] =
+ for
+ prefs <- run(
+ query[DashboardPreference]
+ .filter(p => p.userId == lift(userId) && p.context == lift(context))
+ )
+ result <- prefs.headOption match
+ case Some(pref) => ZIO.fromEither(
+ pref.widgets.as[List[StoredWidgetConfig]]
+ ).orElse(getDefaultLayout(context))
+ case None => getDefaultLayout(context)
+ yield result
+
+ def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit] =
+ val widgetsJson = widgets.asJson
+ val now = java.time.OffsetDateTime.now()
+ for
+ existing <- run(
+ query[DashboardPreference]
+ .filter(p => p.userId == lift(userId) && p.context == lift(context))
+ )
+ _ <- existing.headOption match
+ case Some(pref) =>
+ run(
+ query[DashboardPreference]
+ .filter(_.id == lift(pref.id))
+ .update(
+ _.widgets -> lift(widgetsJson),
+ _.updatedAt -> lift(now)
+ )
+ ).unit
+ case None =>
+ run(
+ query[DashboardPreference]
+ .insert(
+ _.userId -> lift(userId),
+ _.context -> lift(context),
+ _.widgets -> lift(widgetsJson),
+ _.createdAt -> lift(now),
+ _.updatedAt -> lift(now)
+ )
+ ).unit
+ yield ()
+
+ def resetToDefault(userId: Long, context: String): Task[Unit] =
+ run(
+ query[DashboardPreference]
+ .filter(p => p.userId == lift(userId) && p.context == lift(context))
+ .delete
+ ).unit
+
+ def getDefaultLayout(context: String): Task[List[StoredWidgetConfig]] =
+ // Default layout - will be populated when core widgets registered
+ ZIO.succeed(List(
+ StoredWidgetConfig(
+ widgetId = "welcome",
+ alias = "welcome_default",
+ sortOrder = 10,
+ configuration = Map(
+ "x" -> Json.fromInt(0),
+ "y" -> Json.fromInt(0),
+ "w" -> Json.fromInt(7),
+ "h" -> Json.fromInt(3)
+ )
+ ),
+ StoredWidgetConfig(
+ widgetId = "system-status",
+ alias = "status_default",
+ sortOrder = 20,
+ configuration = Map(
+ "x" -> Json.fromInt(7),
+ "y" -> Json.fromInt(0),
+ "w" -> Json.fromInt(5),
+ "h" -> Json.fromInt(3)
+ )
+ )
+ ))
+ }
+ }
+```
+
+Ensure Quill can handle JSONB columns - may need to add circe-json encoder/decoder for Quill if not already present. Check existing QuillContext for custom encoders.
+
+
+ mill summercms.db.flywayMigrate runs migration V005 successfully
+ mill summercms.admin.compile succeeds
+ DashboardPreferenceService compiles with Quill queries
+
+
+ Migration V005 creates dashboard_preferences table with JSONB widgets column
+ DashboardPreference model maps to table
+ StoredWidgetConfig has circe codecs for JSON serialization
+ DashboardPreferenceService provides get/set/reset operations
+ Default layout returns welcome + system-status widgets
+
+
+
+
+
+
+1. `mill summercms.admin.compile` succeeds
+2. `mill summercms.db.flywayMigrate` applies V005 migration
+3. Database has dashboard_preferences table with correct schema
+4. All types in DashboardWidget.scala compile
+5. WidgetRegistry.live layer constructs successfully
+
+
+
+- DashboardWidget trait established with render/updates/defineProperties
+- WidgetRegistry collects and resolves widgets by ID
+- DashboardPreferenceService persists per-user layouts to PostgreSQL
+- Migration creates dashboard_preferences table with JSONB support
+- Default layout defined for fresh installations
+
+
+
+After completion, create `.planning/phases/08-admin-dashboard/08-01-SUMMARY.md`
+
diff --git a/.planning/phases/08-admin-dashboard/08-02-PLAN.md b/.planning/phases/08-admin-dashboard/08-02-PLAN.md
new file mode 100644
index 0000000..64cdd97
--- /dev/null
+++ b/.planning/phases/08-admin-dashboard/08-02-PLAN.md
@@ -0,0 +1,426 @@
+---
+phase: 08-admin-dashboard
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - summercms/admin/src/settings/SettingsManager.scala
+ - summercms/admin/src/settings/categories.scala
+ - summercms/admin/src/settings/SettingsModel.scala
+ - summercms/db/src/main/resources/db/migration/V006__system_settings.sql
+autonomous: true
+
+must_haves:
+ truths:
+ - "Plugins can register settings pages with category and permissions"
+ - "Settings are grouped by category when displayed"
+ - "Settings models persist values to database using YAML-driven forms"
+ artifacts:
+ - path: "summercms/admin/src/settings/SettingsManager.scala"
+ provides: "Registry for plugin settings pages"
+ exports: ["SettingsManager", "SettingsItem"]
+ - path: "summercms/admin/src/settings/categories.scala"
+ provides: "Standard settings categories"
+ contains: "object SettingsCategory"
+ - path: "summercms/admin/src/settings/SettingsModel.scala"
+ provides: "Behavior for YAML-driven settings persistence"
+ contains: "trait SettingsModel"
+ - path: "summercms/db/src/main/resources/db/migration/V006__system_settings.sql"
+ provides: "System settings storage table"
+ contains: "CREATE TABLE system_settings"
+ key_links:
+ - from: "SettingsManager"
+ to: "SettingsItem"
+ via: "registerSettings accepts items"
+ pattern: "def registerSettings.*SettingsItem"
+ - from: "SettingsModel"
+ to: "system_settings table"
+ via: "loadFromDatabase/saveToDatabase"
+ pattern: "system_settings"
+---
+
+
+Create the plugin settings system: a manager for registering settings pages, standard categories for grouping, and a model behavior for YAML-driven settings persistence.
+
+Purpose: Enable plugins to define settings pages using the same YAML-driven form system from Phase 7
+Output: SettingsManager, SettingsCategory, SettingsModel, migration
+
+
+
+@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
+@/home/jin/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/08-admin-dashboard/08-RESEARCH.md
+
+# Phase 1 Quill patterns
+@summercms/db/src/main/scala/summercms/db/QuillContext.scala
+
+
+
+
+
+ Task 1: Settings Manager and Categories
+
+ summercms/admin/src/settings/SettingsManager.scala
+ summercms/admin/src/settings/categories.scala
+
+
+Create the settings registration system:
+
+**categories.scala** - Standard settings categories with sort order:
+
+```scala
+package summercms.admin.settings
+
+/** Standard settings categories following WinterCMS conventions */
+object SettingsCategory:
+ val System = CategoryDef("system", "System", 100)
+ val CMS = CategoryDef("cms", "CMS", 200)
+ val Users = CategoryDef("users", "Users", 300)
+ val Mail = CategoryDef("mail", "Mail", 400)
+ val Misc = CategoryDef("misc", "Miscellaneous", 900)
+
+ val all: List[CategoryDef] = List(System, CMS, Users, Mail, Misc)
+
+ def byCode(code: String): Option[CategoryDef] =
+ all.find(_.code == code)
+
+case class CategoryDef(
+ code: String,
+ label: String,
+ order: Int
+)
+```
+
+**SettingsManager.scala** - Registry for plugin settings:
+
+```scala
+package summercms.admin.settings
+
+import zio._
+
+/** A registered settings page */
+case class SettingsItem(
+ code: String,
+ owner: String, // Plugin ID: "golem15.blog"
+ label: String, // Display name or translation key
+ description: Option[String] = None,
+ category: String, // Category code (system, cms, users, etc.)
+ icon: Option[String] = None,
+ url: Option[String] = None, // Custom URL or auto-generated
+ settingsClass: Option[String] = None, // Settings model class name
+ permissions: List[String] = Nil,
+ order: Int = 500,
+ keywords: Option[String] = None // For search
+)
+
+/** Context tracking current settings page being edited */
+case class SettingsContext(
+ owner: String,
+ code: String
+)
+
+/** Registry for plugin settings pages */
+trait SettingsManager:
+ /** Register settings items from a plugin */
+ def registerSettings(owner: String, items: List[SettingsItem]): UIO[Unit]
+
+ /** List all items grouped by category */
+ def listItems: UIO[Map[String, List[SettingsItem]]]
+
+ /** Find a specific settings item */
+ def findItem(owner: String, code: String): UIO[Option[SettingsItem]]
+
+ /** Set current context for settings operations */
+ def setContext(owner: String, code: String): UIO[Unit]
+
+ /** Get current context */
+ def getContext: UIO[Option[SettingsContext]]
+
+object SettingsManager:
+ val live: ZLayer[Any, Nothing, SettingsManager] =
+ ZLayer.fromZIO {
+ for
+ itemsRef <- Ref.make(Map.empty[String, SettingsItem])
+ contextRef <- Ref.make(Option.empty[SettingsContext])
+ yield new SettingsManager:
+ def registerSettings(owner: String, items: List[SettingsItem]): UIO[Unit] =
+ itemsRef.update { current =>
+ items.foldLeft(current) { case (acc, item) =>
+ val key = s"${owner.toUpperCase}.${item.code.toUpperCase}"
+ val itemWithOwner = item.copy(
+ owner = owner,
+ url = item.url.orElse(Some(generateSettingsUrl(owner, item.code)))
+ )
+ acc + (key -> itemWithOwner)
+ }
+ }
+
+ def listItems: UIO[Map[String, List[SettingsItem]]] =
+ itemsRef.get.map { items =>
+ items.values.toList
+ .groupBy(_.category)
+ .view
+ .mapValues(_.sortBy(_.order))
+ .toMap
+ }
+
+ def findItem(owner: String, code: String): UIO[Option[SettingsItem]] =
+ itemsRef.get.map(_.get(s"${owner.toUpperCase}.${code.toUpperCase}"))
+
+ def setContext(owner: String, code: String): UIO[Unit] =
+ contextRef.set(Some(SettingsContext(owner.toLowerCase, code.toLowerCase)))
+
+ def getContext: UIO[Option[SettingsContext]] =
+ contextRef.get
+
+ private def generateSettingsUrl(owner: String, code: String): String =
+ val parts = owner.split('.')
+ s"/admin/system/settings/update/${parts.mkString("/")}/$code"
+ }
+
+ // Accessor methods
+ def registerSettings(owner: String, items: List[SettingsItem]): ZIO[SettingsManager, Nothing, Unit] =
+ ZIO.serviceWithZIO[SettingsManager](_.registerSettings(owner, items))
+
+ def listItems: ZIO[SettingsManager, Nothing, Map[String, List[SettingsItem]]] =
+ ZIO.serviceWithZIO[SettingsManager](_.listItems)
+
+ def findItem(owner: String, code: String): ZIO[SettingsManager, Nothing, Option[SettingsItem]] =
+ ZIO.serviceWithZIO[SettingsManager](_.findItem(owner, code))
+```
+
+Categories follow WinterCMS pattern. Plugins can use existing categories or define custom ones (just use any string code).
+
+
+ mill summercms.admin.compile succeeds
+ SettingsCategory.all contains System, CMS, Users, Mail, Misc
+ SettingsManager.live layer constructs
+
+
+ SettingsCategory defines standard categories with order
+ SettingsItem captures all metadata for a settings page
+ SettingsManager provides registration and lookup with ZIO Ref
+ Auto-generated URLs follow /admin/system/settings/update/{author}/{plugin}/{code} pattern
+
+
+
+
+ Task 2: Settings Model Behavior
+
+ summercms/admin/src/settings/SettingsModel.scala
+ summercms/db/src/main/resources/db/migration/V006__system_settings.sql
+
+
+Create the settings persistence layer:
+
+**V006__system_settings.sql** - Migration for settings storage:
+
+```sql
+-- System settings storage (key-value with JSON)
+CREATE TABLE system_settings (
+ id BIGSERIAL PRIMARY KEY,
+ item VARCHAR(255) NOT NULL UNIQUE,
+ value JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_system_settings_item ON system_settings(item);
+
+-- Trigger to update updated_at
+CREATE TRIGGER update_system_settings_updated_at
+ BEFORE UPDATE ON system_settings
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+```
+
+**SettingsModel.scala** - Trait for settings persistence:
+
+```scala
+package summercms.admin.settings
+
+import io.circe._
+import io.circe.syntax._
+import zio._
+import summercms.db.QuillContext
+import java.time.OffsetDateTime
+
+/** Database row for system_settings */
+case class SystemSetting(
+ id: Long,
+ item: String,
+ value: Json,
+ createdAt: OffsetDateTime,
+ updatedAt: OffsetDateTime
+)
+
+/** Base trait for plugin settings models */
+trait SettingsModel:
+ /** Unique code identifying this settings group */
+ def settingsCode: String
+
+ /** Path to fields.yaml defining the form */
+ def settingsFields: String
+
+ /** Initialize default values (override in subclass) */
+ def initSettingsData(): Map[String, Json] = Map.empty
+
+/** Service for settings model operations */
+trait SettingsModelService:
+ /** Get settings values, loading from DB or defaults */
+ def get(model: SettingsModel): Task[Map[String, Json]]
+
+ /** Get a single setting value */
+ def getValue[T: Decoder](model: SettingsModel, key: String, default: T): Task[T]
+
+ /** Set settings values */
+ def set(model: SettingsModel, values: Map[String, Json]): Task[Unit]
+
+ /** Reset to defaults by deleting from DB */
+ def resetDefault(model: SettingsModel): Task[Unit]
+
+object SettingsModelService:
+ val live: ZLayer[QuillContext, Nothing, SettingsModelService] =
+ ZLayer.fromZIO {
+ ZIO.service[QuillContext].map { quill =>
+ import quill.ctx._
+
+ // Cache for loaded settings
+ val cache = new java.util.concurrent.ConcurrentHashMap[String, Map[String, Json]]()
+
+ new SettingsModelService:
+ def get(model: SettingsModel): Task[Map[String, Json]] =
+ // Check cache first
+ Option(cache.get(model.settingsCode)) match
+ case Some(values) => ZIO.succeed(values)
+ case None =>
+ for
+ rows <- run(
+ query[SystemSetting]
+ .filter(_.item == lift(model.settingsCode))
+ )
+ values <- rows.headOption match
+ case Some(row) =>
+ ZIO.fromEither(row.value.as[Map[String, Json]])
+ .mapError(e => new RuntimeException(s"Failed to decode settings: ${e.getMessage}"))
+ case None =>
+ ZIO.succeed(model.initSettingsData())
+ _ <- ZIO.succeed(cache.put(model.settingsCode, values))
+ yield values
+
+ def getValue[T: Decoder](model: SettingsModel, key: String, default: T): Task[T] =
+ get(model).map { values =>
+ values.get(key)
+ .flatMap(_.as[T].toOption)
+ .getOrElse(default)
+ }
+
+ def set(model: SettingsModel, values: Map[String, Json]): Task[Unit] =
+ val valuesJson = values.asJson
+ val now = OffsetDateTime.now()
+ for
+ existing <- run(
+ query[SystemSetting]
+ .filter(_.item == lift(model.settingsCode))
+ )
+ _ <- existing.headOption match
+ case Some(row) =>
+ run(
+ query[SystemSetting]
+ .filter(_.id == lift(row.id))
+ .update(
+ _.value -> lift(valuesJson),
+ _.updatedAt -> lift(now)
+ )
+ ).unit
+ case None =>
+ run(
+ query[SystemSetting]
+ .insert(
+ _.item -> lift(model.settingsCode),
+ _.value -> lift(valuesJson),
+ _.createdAt -> lift(now),
+ _.updatedAt -> lift(now)
+ )
+ ).unit
+ // Invalidate cache
+ _ <- ZIO.succeed(cache.remove(model.settingsCode))
+ yield ()
+
+ def resetDefault(model: SettingsModel): Task[Unit] =
+ for
+ _ <- run(
+ query[SystemSetting]
+ .filter(_.item == lift(model.settingsCode))
+ .delete
+ )
+ _ <- ZIO.succeed(cache.remove(model.settingsCode))
+ yield ()
+ }
+ }
+
+ // Accessor methods
+ def get(model: SettingsModel): ZIO[SettingsModelService, Throwable, Map[String, Json]] =
+ ZIO.serviceWithZIO[SettingsModelService](_.get(model))
+
+ def set(model: SettingsModel, values: Map[String, Json]): ZIO[SettingsModelService, Throwable, Unit] =
+ ZIO.serviceWithZIO[SettingsModelService](_.set(model, values))
+```
+
+The SettingsModelService provides caching to avoid repeated DB hits. Cache is invalidated on write. Plugin settings models implement SettingsModel trait with their settingsCode and fields path.
+
+Example usage by a plugin:
+
+```scala
+// In a plugin
+object BlogSettings extends SettingsModel:
+ val settingsCode = "golem15_blog_settings"
+ val settingsFields = "$/plugins/golem15/blog/models/settings/fields.yaml"
+
+ override def initSettingsData(): Map[String, Json] = Map(
+ "posts_per_page" -> Json.fromInt(10),
+ "show_author" -> Json.fromBoolean(true)
+ )
+```
+
+
+ mill summercms.db.flywayMigrate runs migration V006 successfully
+ mill summercms.admin.compile succeeds
+ SettingsModelService compiles with get/set/resetDefault
+
+
+ Migration V006 creates system_settings table with JSONB value column
+ SystemSetting model maps to table
+ SettingsModel trait defines settingsCode and settingsFields
+ SettingsModelService provides get/set with caching
+ Cache invalidation on write prevents stale reads
+
+
+
+
+
+
+1. `mill summercms.admin.compile` succeeds
+2. `mill summercms.db.flywayMigrate` applies V006 migration
+3. Database has system_settings table with correct schema
+4. SettingsCategory.byCode("system") returns System category
+5. SettingsManager and SettingsModelService layers construct
+
+
+
+- SettingsCategory provides standard categories (System, CMS, Users, Mail, Misc)
+- SettingsManager registers and groups settings by category
+- SettingsModel trait established for plugin settings
+- SettingsModelService persists to system_settings table with caching
+- Migration creates system_settings with JSONB support
+
+
+
+After completion, create `.planning/phases/08-admin-dashboard/08-02-SUMMARY.md`
+
diff --git a/.planning/phases/08-admin-dashboard/08-03-PLAN.md b/.planning/phases/08-admin-dashboard/08-03-PLAN.md
new file mode 100644
index 0000000..99b4f9f
--- /dev/null
+++ b/.planning/phases/08-admin-dashboard/08-03-PLAN.md
@@ -0,0 +1,862 @@
+---
+phase: 08-admin-dashboard
+plan: 03
+type: execute
+wave: 2
+depends_on: ["08-01"]
+files_modified:
+ - summercms/admin/src/dashboard/Dashboard.scala
+ - summercms/admin/src/dashboard/DashboardController.scala
+ - summercms/admin/src/dashboard/widgets/WelcomeWidget.scala
+ - summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala
+ - summercms/admin/src/dashboard/widgets/QuickActionsWidget.scala
+ - summercms/admin/src/dashboard/sse/WidgetEventStream.scala
+autonomous: true
+
+must_haves:
+ truths:
+ - "Admin sees dashboard with Gridstack.js grid after login"
+ - "Widgets can be dragged, resized, and layout is saved"
+ - "Real-time widgets receive updates via SSE"
+ - "Default widgets display on fresh install"
+ artifacts:
+ - path: "summercms/admin/src/dashboard/Dashboard.scala"
+ provides: "Dashboard container with Gridstack.js integration"
+ contains: "def renderDashboard"
+ - path: "summercms/admin/src/dashboard/DashboardController.scala"
+ provides: "HTTP routes for dashboard operations"
+ exports: ["DashboardController"]
+ - path: "summercms/admin/src/dashboard/sse/WidgetEventStream.scala"
+ provides: "SSE endpoint for widget updates"
+ contains: "ServerSentEvent"
+ - path: "summercms/admin/src/dashboard/widgets/WelcomeWidget.scala"
+ provides: "Welcome widget for new users"
+ contains: "class WelcomeWidget"
+ - path: "summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala"
+ provides: "System status display widget"
+ contains: "class SystemStatusWidget"
+ key_links:
+ - from: "DashboardController"
+ to: "DashboardPreferenceService"
+ via: "loads/saves user preferences"
+ pattern: "prefsService\\.(get|set)Preferences"
+ - from: "Dashboard.scala"
+ to: "WidgetEventStream"
+ via: "sse-connect attribute in Gridstack items"
+ pattern: "sse-connect.*widget-stream"
+ - from: "Dashboard.scala"
+ to: "Gridstack.js"
+ via: "grid-stack classes and initialization"
+ pattern: "grid-stack"
+---
+
+
+Build the dashboard container with Gridstack.js grid layout, core widgets (Welcome, SystemStatus, QuickActions), and SSE endpoint for real-time widget updates.
+
+Purpose: Deliver the customizable dashboard admins see after login
+Output: Dashboard container, DashboardController, SSE endpoint, 3 core widgets
+
+
+
+@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
+@/home/jin/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/08-admin-dashboard/08-RESEARCH.md
+
+# Depends on 08-01 output
+@summercms/admin/src/dashboard/DashboardWidget.scala
+@summercms/admin/src/dashboard/WidgetRegistry.scala
+@summercms/admin/src/dashboard/DashboardPreferenceService.scala
+
+
+
+
+
+ Task 1: Dashboard Container and Controller
+
+ summercms/admin/src/dashboard/Dashboard.scala
+ summercms/admin/src/dashboard/DashboardController.scala
+
+
+Create the dashboard container and HTTP routes:
+
+**Dashboard.scala** - Render the dashboard with Gridstack.js:
+
+```scala
+package summercms.admin.dashboard
+
+import scalatags.Text.all._
+import scalatags.Text.tags2.title as pageTitle
+import zio._
+
+object Dashboard:
+ /** Render full dashboard page */
+ def renderDashboard(
+ widgets: List[(String, DashboardWidget, WidgetConfig)],
+ canAddAndDelete: Boolean,
+ ctx: DashboardContext
+ ): Task[Frag] =
+ for
+ renderedWidgets <- ZIO.foreach(widgets) { case (alias, widget, config) =>
+ widget.render(config, ctx).map(content => (alias, widget, config, content))
+ }
+ yield frag(
+ // Toolbar
+ div(cls := "dashboard-toolbar mb-3 d-flex gap-2",
+ if canAddAndDelete then
+ button(cls := "btn btn-primary",
+ attr("hx-get") := "/admin/dashboard/add-widget-popup",
+ attr("hx-target") := "#modal-container",
+ i(cls := "icon-plus me-1"), "Add Widget"
+ )
+ else frag(),
+ button(cls := "btn btn-secondary",
+ attr("hx-post") := "/admin/dashboard/reset",
+ attr("hx-confirm") := "Reset dashboard to default layout?",
+ i(cls := "icon-refresh me-1"), "Reset to Default"
+ )
+ ),
+
+ // Grid container
+ div(cls := "grid-stack", id := "dashboard-grid",
+ renderedWidgets.map { case (alias, widget, config, content) =>
+ renderGridItem(alias, widget, config, content, canAddAndDelete)
+ }
+ ),
+
+ // Gridstack initialization
+ script(raw(s"""
+ document.addEventListener('DOMContentLoaded', function() {
+ var grid = GridStack.init({
+ cellHeight: 80,
+ margin: 10,
+ float: true,
+ removable: false,
+ animate: true
+ });
+
+ // Debounce save (500ms after last change)
+ var saveTimeout = null;
+ grid.on('change', function(event, items) {
+ if (saveTimeout) clearTimeout(saveTimeout);
+ saveTimeout = setTimeout(function() {
+ var layout = items.map(function(item) {
+ return {
+ alias: item.id,
+ x: item.x, y: item.y,
+ w: item.w, h: item.h
+ };
+ });
+ htmx.ajax('POST', '/admin/dashboard/save-layout', {
+ values: { layout: JSON.stringify(layout) }
+ });
+ }, 500);
+ });
+ });
+ """))
+ )
+
+ /** Render a single grid item */
+ private def renderGridItem(
+ alias: String,
+ widget: DashboardWidget,
+ config: WidgetConfig,
+ content: Frag,
+ canDelete: Boolean
+ ): Frag =
+ val pos = config.position
+ val hasUpdates = widget.updates(config).isDefined
+
+ div(cls := "grid-stack-item",
+ attr("gs-id") := alias,
+ attr("gs-x") := pos.x.toString,
+ attr("gs-y") := pos.y.toString,
+ attr("gs-w") := pos.w.toString,
+ attr("gs-h") := pos.h.toString,
+ attr("gs-min-w") := "2",
+ attr("gs-min-h") := "2",
+
+ div(cls := "grid-stack-item-content widget-container card",
+ // SSE connection for real-time updates
+ if hasUpdates then Seq(
+ attr("hx-ext") := "sse",
+ attr("sse-connect") := s"/admin/dashboard/widget-stream/$alias",
+ attr("sse-swap") := "content"
+ ) else Nil,
+
+ // Widget header
+ div(cls := "card-header d-flex justify-content-between align-items-center",
+ span(cls := "widget-title fw-bold",
+ config.properties.get("title").map(_.toString).getOrElse(widget.name)
+ ),
+ div(cls := "widget-controls",
+ button(cls := "btn btn-sm btn-link",
+ attr("hx-get") := s"/admin/dashboard/widget-config/$alias",
+ attr("hx-target") := "#modal-container",
+ attr("title") := "Configure",
+ i(cls := "icon-cog")
+ ),
+ if canDelete then
+ button(cls := "btn btn-sm btn-link text-danger",
+ attr("hx-post") := s"/admin/dashboard/remove-widget/$alias",
+ attr("hx-confirm") := "Remove this widget?",
+ attr("title") := "Remove",
+ i(cls := "icon-trash")
+ )
+ else frag()
+ )
+ ),
+
+ // Widget body
+ div(cls := "card-body widget-body", id := s"widget-content-$alias",
+ content
+ )
+ )
+ )
+
+ /** Render add widget popup */
+ def renderAddWidgetPopup(
+ available: Map[String, (WidgetInfo, DashboardWidget)]
+ ): Frag =
+ div(cls := "modal-dialog modal-lg",
+ div(cls := "modal-content",
+ div(cls := "modal-header",
+ h5(cls := "modal-title", "Add Widget"),
+ button(`type` := "button", cls := "btn-close", attr("data-bs-dismiss") := "modal")
+ ),
+ div(cls := "modal-body",
+ div(cls := "row g-3",
+ available.toList.sortBy(_._2._1.name).map { case (widgetId, (info, widget)) =>
+ div(cls := "col-md-6",
+ div(cls := "card h-100 widget-option",
+ attr("hx-post") := s"/admin/dashboard/add-widget",
+ attr("hx-vals") := s"""{"widgetId": "$widgetId"}""",
+ attr("hx-swap") := "none",
+ attr("onclick") := "bootstrap.Modal.getInstance(this.closest('.modal')).hide()",
+ div(cls := "card-body",
+ h6(cls := "card-title", info.name),
+ p(cls := "card-text text-muted small", info.description)
+ )
+ )
+ )
+ }
+ )
+ )
+ )
+ )
+```
+
+**DashboardController.scala** - HTTP routes:
+
+```scala
+package summercms.admin.dashboard
+
+import zio._
+import zio.http._
+import io.circe._
+import io.circe.parser._
+import io.circe.syntax._
+
+class DashboardController(
+ widgetRegistry: WidgetRegistry,
+ prefsService: DashboardPreferenceService
+):
+ def routes: Routes[Any, Nothing] =
+ Routes(
+ // Main dashboard page
+ Method.GET / "admin" -> handler { (req: Request) =>
+ renderIndex(req)
+ },
+
+ // Save layout (AJAX)
+ Method.POST / "admin" / "dashboard" / "save-layout" -> handler { (req: Request) =>
+ saveLayout(req)
+ },
+
+ // Add widget popup
+ Method.GET / "admin" / "dashboard" / "add-widget-popup" -> handler { (req: Request) =>
+ addWidgetPopup(req)
+ },
+
+ // Add widget
+ Method.POST / "admin" / "dashboard" / "add-widget" -> handler { (req: Request) =>
+ addWidget(req)
+ },
+
+ // Remove widget
+ Method.POST / "admin" / "dashboard" / "remove-widget" / string("alias") -> handler {
+ (alias: String, req: Request) => removeWidget(alias, req)
+ },
+
+ // Reset to default
+ Method.POST / "admin" / "dashboard" / "reset" -> handler { (req: Request) =>
+ resetDashboard(req)
+ },
+
+ // Widget config popup
+ Method.GET / "admin" / "dashboard" / "widget-config" / string("alias") -> handler {
+ (alias: String, req: Request) => widgetConfigPopup(alias, req)
+ }
+ )
+
+ private def renderIndex(req: Request): ZIO[Any, Nothing, Response] =
+ (for
+ userId <- extractUserId(req)
+ ctx = DashboardContext(userId, "Admin", Set.empty) // TODO: real user
+ configs <- prefsService.getPreferences(userId, "dashboard")
+ widgets <- ZIO.foreach(configs) { stored =>
+ widgetRegistry.resolveWidget(stored.widgetId).map { maybeWidget =>
+ maybeWidget.map { widget =>
+ (stored.alias, widget, toWidgetConfig(stored))
+ }
+ }
+ }.map(_.flatten)
+ html <- Dashboard.renderDashboard(widgets, canAddAndDelete = true, ctx)
+ yield Response.html(wrapInLayout(html))).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
+ }
+
+ private def saveLayout(req: Request): ZIO[Any, Nothing, Response] =
+ (for
+ userId <- extractUserId(req)
+ body <- req.body.asString
+ form <- ZIO.fromEither(parseFormData(body))
+ layoutJson <- ZIO.fromOption(form.get("layout"))
+ .orElseFail(new RuntimeException("Missing layout"))
+ layoutUpdates <- ZIO.fromEither(parse(layoutJson).flatMap(_.as[List[LayoutUpdate]]))
+ currentPrefs <- prefsService.getPreferences(userId, "dashboard")
+
+ // Merge layout updates into current preferences
+ updatedPrefs = currentPrefs.map { stored =>
+ layoutUpdates.find(_.alias == stored.alias) match
+ case Some(update) =>
+ stored.copy(configuration = stored.configuration ++ Map(
+ "x" -> Json.fromInt(update.x),
+ "y" -> Json.fromInt(update.y),
+ "w" -> Json.fromInt(update.w),
+ "h" -> Json.fromInt(update.h)
+ ))
+ case None => stored
+ }
+
+ _ <- prefsService.setPreferences(userId, "dashboard", updatedPrefs)
+ yield Response.ok).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.BadRequest))
+ }
+
+ private def addWidgetPopup(req: Request): ZIO[Any, Nothing, Response] =
+ (for
+ available <- widgetRegistry.listWidgetsForContext("dashboard")
+ html = Dashboard.renderAddWidgetPopup(available)
+ yield Response.html(html.render)).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
+ }
+
+ private def addWidget(req: Request): ZIO[Any, Nothing, Response] =
+ (for
+ userId <- extractUserId(req)
+ body <- req.body.asString
+ form <- ZIO.fromEither(parseFormData(body))
+ widgetId <- ZIO.fromOption(form.get("widgetId"))
+ .orElseFail(new RuntimeException("Missing widgetId"))
+ widget <- widgetRegistry.resolveWidget(widgetId)
+ .someOrFail(new RuntimeException(s"Unknown widget: $widgetId"))
+
+ currentPrefs <- prefsService.getPreferences(userId, "dashboard")
+ nextOrder = currentPrefs.map(_.sortOrder).maxOption.getOrElse(0) + 10
+ alias = s"widget_${System.currentTimeMillis()}"
+
+ newConfig = StoredWidgetConfig(
+ widgetId = widgetId,
+ alias = alias,
+ sortOrder = nextOrder,
+ configuration = Map(
+ "x" -> Json.fromInt(0),
+ "y" -> Json.fromInt(currentPrefs.size),
+ "w" -> Json.fromInt(widget.defaultPosition.w),
+ "h" -> Json.fromInt(widget.defaultPosition.h)
+ )
+ )
+
+ _ <- prefsService.setPreferences(userId, "dashboard", currentPrefs :+ newConfig)
+ yield Response.ok.addHeader(Header.Custom("HX-Refresh", "true"))).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.BadRequest))
+ }
+
+ private def removeWidget(alias: String, req: Request): ZIO[Any, Nothing, Response] =
+ (for
+ userId <- extractUserId(req)
+ currentPrefs <- prefsService.getPreferences(userId, "dashboard")
+ updatedPrefs = currentPrefs.filterNot(_.alias == alias)
+ _ <- prefsService.setPreferences(userId, "dashboard", updatedPrefs)
+ yield Response.ok.addHeader(Header.Custom("HX-Refresh", "true"))).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
+ }
+
+ private def resetDashboard(req: Request): ZIO[Any, Nothing, Response] =
+ (for
+ userId <- extractUserId(req)
+ _ <- prefsService.resetToDefault(userId, "dashboard")
+ yield Response.redirect(URL.decode("/admin").toOption.get)).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
+ }
+
+ // Helper methods
+ private def extractUserId(req: Request): Task[Long] =
+ // TODO: Extract from session/JWT. For now, return test user ID
+ ZIO.succeed(1L)
+
+ private def toWidgetConfig(stored: StoredWidgetConfig): WidgetConfig =
+ WidgetConfig(
+ alias = stored.alias,
+ properties = stored.configuration.view.mapValues(jsonToAny).toMap,
+ position = WidgetPosition(
+ x = stored.configuration.get("x").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
+ y = stored.configuration.get("y").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
+ w = stored.configuration.get("w").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(6),
+ h = stored.configuration.get("h").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(2)
+ )
+ )
+
+ private def jsonToAny(json: Json): Any =
+ json.fold(
+ jsonNull = null,
+ jsonBoolean = identity,
+ jsonNumber = n => n.toInt.getOrElse(n.toDouble),
+ jsonString = identity,
+ jsonArray = _.map(jsonToAny).toList,
+ jsonObject = _.toMap.view.mapValues(jsonToAny).toMap
+ )
+
+ private def parseFormData(body: String): Either[Throwable, Map[String, String]] =
+ Right(body.split("&").flatMap { pair =>
+ pair.split("=", 2) match
+ case Array(k, v) => Some(java.net.URLDecoder.decode(k, "UTF-8") -> java.net.URLDecoder.decode(v, "UTF-8"))
+ case _ => None
+ }.toMap)
+
+ private def wrapInLayout(content: Frag): String =
+ // TODO: Use proper admin layout from Phase 7
+ s"""
+
+
+ Dashboard - SummerCMS
+
+
+
+
+
+
+ ${content.render}
+
+
+
+
+
+
+
+ """
+
+case class LayoutUpdate(alias: String, x: Int, y: Int, w: Int, h: Int)
+object LayoutUpdate:
+ given Decoder[LayoutUpdate] = io.circe.generic.semiauto.deriveDecoder
+```
+
+Note: extractUserId returns hardcoded 1L for now - will be integrated with Phase 6 auth.
+
+
+ mill summercms.admin.compile succeeds
+ Dashboard.renderDashboard generates HTML with grid-stack classes
+ DashboardController routes compile
+
+
+ Dashboard.scala renders Gridstack.js grid with widget items
+ Gridstack initialization includes debounced save on layout change
+ DashboardController handles index, save-layout, add/remove widget, reset
+ Layout merges position updates from Gridstack.js into stored preferences
+
+
+
+
+ Task 2: Core Widgets
+
+ summercms/admin/src/dashboard/widgets/WelcomeWidget.scala
+ summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala
+ summercms/admin/src/dashboard/widgets/QuickActionsWidget.scala
+
+
+Create the default dashboard widgets:
+
+**WelcomeWidget.scala** - Welcome message for admins:
+
+```scala
+package summercms.admin.dashboard.widgets
+
+import scalatags.Text.all._
+import zio._
+import summercms.admin.dashboard._
+
+class WelcomeWidget extends DashboardWidget:
+ val widgetId = "welcome"
+ val name = "Welcome"
+ val description = "Welcome message with getting started tips"
+
+ override def defaultPosition: WidgetPosition = WidgetPosition(0, 0, 7, 3)
+
+ def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
+ ZIO.succeed(
+ div(cls := "welcome-widget",
+ h4(cls := "mb-3", s"Welcome back, ${ctx.userName}!"),
+ p(cls := "text-muted",
+ "This is your SummerCMS dashboard. Use the widgets below to monitor your site and access quick actions."
+ ),
+ div(cls := "mt-3",
+ h6("Getting Started"),
+ ul(cls := "list-unstyled",
+ li(cls := "mb-2",
+ i(cls := "icon-book me-2"),
+ a(href := "/admin/docs", "Read the documentation")
+ ),
+ li(cls := "mb-2",
+ i(cls := "icon-puzzle me-2"),
+ a(href := "/admin/system/updates", "Install plugins")
+ ),
+ li(cls := "mb-2",
+ i(cls := "icon-brush me-2"),
+ a(href := "/admin/cms/themes", "Customize your theme")
+ )
+ )
+ )
+ )
+ )
+```
+
+**SystemStatusWidget.scala** - System health information:
+
+```scala
+package summercms.admin.dashboard.widgets
+
+import scalatags.Text.all._
+import zio._
+import zio.stream._
+import summercms.admin.dashboard._
+
+class SystemStatusWidget extends DashboardWidget:
+ val widgetId = "system-status"
+ val name = "System Status"
+ val description = "Shows system health and resource usage"
+
+ override def defaultPosition: WidgetPosition = WidgetPosition(7, 0, 5, 3)
+
+ override def defineProperties: Map[String, PropertyDef] =
+ super.defineProperties ++ Map(
+ "showMemory" -> PropertyDef(
+ title = "Show Memory Usage",
+ `type` = PropertyType.Checkbox,
+ default = Some("true")
+ )
+ )
+
+ def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
+ for
+ runtime <- ZIO.succeed(Runtime.getRuntime)
+ usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
+ maxMemory = runtime.maxMemory() / (1024 * 1024)
+ cpuCount = runtime.availableProcessors()
+ javaVersion = System.getProperty("java.version")
+ scalaVersion = util.Properties.versionNumberString
+ yield div(cls := "system-status-widget",
+ div(cls := "row g-3",
+ // JVM Info
+ div(cls := "col-6",
+ div(cls := "stat-card p-2 bg-light rounded",
+ div(cls := "stat-value h5 mb-0", s"Java $javaVersion"),
+ div(cls := "stat-label small text-muted", "JVM Version")
+ )
+ ),
+ div(cls := "col-6",
+ div(cls := "stat-card p-2 bg-light rounded",
+ div(cls := "stat-value h5 mb-0", s"$cpuCount"),
+ div(cls := "stat-label small text-muted", "CPU Cores")
+ )
+ ),
+ // Memory (if enabled)
+ if config.properties.get("showMemory").exists(_ == true) then
+ frag(
+ div(cls := "col-12 mt-2",
+ div(cls := "memory-bar",
+ label(cls := "small text-muted", s"Memory: $usedMemory MB / $maxMemory MB"),
+ div(cls := "progress mt-1",
+ div(cls := "progress-bar",
+ style := s"width: ${(usedMemory.toDouble / maxMemory * 100).toInt}%",
+ role := "progressbar"
+ )
+ )
+ )
+ )
+ )
+ else frag()
+ )
+ )
+
+ // Real-time updates every 30 seconds
+ override def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] =
+ Some(
+ ZStream.repeatZIOWithSchedule(
+ render(config, DashboardContext(0, "", Set.empty)).map { content =>
+ WidgetUpdate(config.alias, content)
+ }.orDie,
+ Schedule.fixed(30.seconds)
+ )
+ )
+```
+
+**QuickActionsWidget.scala** - Common admin actions:
+
+```scala
+package summercms.admin.dashboard.widgets
+
+import scalatags.Text.all._
+import zio._
+import summercms.admin.dashboard._
+
+class QuickActionsWidget extends DashboardWidget:
+ val widgetId = "quick-actions"
+ val name = "Quick Actions"
+ val description = "Shortcuts to common admin tasks"
+
+ override def defaultPosition: WidgetPosition = WidgetPosition(0, 3, 12, 2)
+
+ def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] =
+ ZIO.succeed(
+ div(cls := "quick-actions-widget",
+ div(cls := "d-flex flex-wrap gap-2",
+ actionButton("/admin/cms/pages/create", "icon-file", "New Page"),
+ actionButton("/admin/blog/posts/create", "icon-pencil", "New Post"),
+ actionButton("/admin/users/create", "icon-user", "New User"),
+ actionButton("/admin/system/settings", "icon-cog", "Settings"),
+ actionButton("/admin/cms/media", "icon-image", "Media Library"),
+ actionButton("/admin/system/updates", "icon-download", "Updates")
+ )
+ )
+ )
+
+ private def actionButton(href: String, icon: String, label: String): Frag =
+ a(cls := "btn btn-outline-secondary", attr("href") := href,
+ i(cls := s"$icon me-1"), label
+ )
+```
+
+Register widgets in application boot:
+
+```scala
+// In main application setup
+for
+ registry <- ZIO.service[WidgetRegistry]
+ _ <- registry.registerWidget(new WelcomeWidget, WidgetInfo("Welcome", "Welcome message"))
+ _ <- registry.registerWidget(new SystemStatusWidget, WidgetInfo("System Status", "System health", permissions = List("system.access_dashboard")))
+ _ <- registry.registerWidget(new QuickActionsWidget, WidgetInfo("Quick Actions", "Common shortcuts"))
+yield ()
+```
+
+
+ mill summercms.admin.compile succeeds
+ WelcomeWidget, SystemStatusWidget, QuickActionsWidget all implement DashboardWidget
+ SystemStatusWidget has updates() returning Some(ZStream)
+
+
+ WelcomeWidget displays welcome message and getting started links
+ SystemStatusWidget shows JVM version, CPU cores, memory usage with real-time updates
+ QuickActionsWidget provides shortcuts to common admin tasks
+ All widgets define defaultPosition for initial layout
+
+
+
+
+ Task 3: SSE Endpoint for Real-Time Updates
+
+ summercms/admin/src/dashboard/sse/WidgetEventStream.scala
+
+
+Create the SSE endpoint for streaming widget updates:
+
+**WidgetEventStream.scala** - Multiplexed SSE for all widgets:
+
+```scala
+package summercms.admin.dashboard.sse
+
+import zio._
+import zio.http._
+import zio.stream._
+import summercms.admin.dashboard._
+
+object WidgetEventStream:
+ /** Routes for SSE widget updates */
+ def routes(
+ widgetRegistry: WidgetRegistry,
+ prefsService: DashboardPreferenceService
+ ): Routes[Any, Nothing] =
+ Routes(
+ // Single widget stream
+ Method.GET / "admin" / "dashboard" / "widget-stream" / string("alias") -> handler {
+ (alias: String, req: Request) =>
+ streamWidgetUpdates(alias, widgetRegistry, prefsService, req)
+ }
+ )
+
+ private def streamWidgetUpdates(
+ alias: String,
+ registry: WidgetRegistry,
+ prefs: DashboardPreferenceService,
+ req: Request
+ ): ZIO[Any, Nothing, Response] =
+ (for
+ userId <- extractUserId(req)
+ configs <- prefs.getPreferences(userId, "dashboard")
+ stored <- ZIO.fromOption(configs.find(_.alias == alias))
+ .orElseFail(new RuntimeException(s"Widget not found: $alias"))
+ widget <- registry.resolveWidget(stored.widgetId)
+ .someOrFail(new RuntimeException(s"Unknown widget: ${stored.widgetId}"))
+
+ config = toWidgetConfig(stored)
+
+ // Get the update stream from the widget (if it provides one)
+ stream = widget.updates(config) match
+ case Some(updateStream) =>
+ // Map widget updates to SSE events
+ updateStream.map { update =>
+ ServerSentEvent(
+ data = Some(update.content.render),
+ eventType = Some("content"),
+ id = Some(java.util.UUID.randomUUID().toString)
+ )
+ }
+ case None =>
+ // Widget doesn't provide updates - just send heartbeats
+ ZStream.tick(30.seconds).map { _ =>
+ ServerSentEvent(
+ data = Some(":heartbeat"),
+ eventType = Some("heartbeat")
+ )
+ }
+
+ yield Response(
+ status = Status.Ok,
+ headers = Headers(
+ Header.ContentType(MediaType.text.`event-stream`),
+ Header.CacheControl.NoCache,
+ Header.Custom("X-Accel-Buffering", "no") // Disable nginx buffering
+ ),
+ body = Body.fromCharSequenceStreamChunked(
+ stream.map(sse => encodeSSE(sse))
+ )
+ )).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.NotFound))
+ }
+
+ /** Encode SSE event to string format */
+ private def encodeSSE(event: ServerSentEvent): String =
+ val sb = new StringBuilder
+ event.id.foreach(id => sb.append(s"id: $id\n"))
+ event.eventType.foreach(t => sb.append(s"event: $t\n"))
+ event.data.foreach { data =>
+ // SSE data must have each line prefixed with "data: "
+ data.split("\n").foreach { line =>
+ sb.append(s"data: $line\n")
+ }
+ }
+ sb.append("\n")
+ sb.toString
+
+ private def extractUserId(req: Request): Task[Long] =
+ // TODO: Extract from session/JWT
+ ZIO.succeed(1L)
+
+ private def toWidgetConfig(stored: StoredWidgetConfig): WidgetConfig =
+ WidgetConfig(
+ alias = stored.alias,
+ properties = stored.configuration.view.mapValues(jsonToAny).toMap,
+ position = WidgetPosition(
+ x = stored.configuration.get("x").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
+ y = stored.configuration.get("y").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(0),
+ w = stored.configuration.get("w").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(6),
+ h = stored.configuration.get("h").flatMap(_.asNumber).flatMap(_.toInt).getOrElse(2)
+ )
+ )
+
+ private def jsonToAny(json: io.circe.Json): Any =
+ json.fold(
+ jsonNull = null,
+ jsonBoolean = identity,
+ jsonNumber = n => n.toInt.getOrElse(n.toDouble),
+ jsonString = identity,
+ jsonArray = _.map(jsonToAny).toList,
+ jsonObject = _.toMap.view.mapValues(jsonToAny).toMap
+ )
+
+/** SSE event representation */
+case class ServerSentEvent(
+ data: Option[String] = None,
+ eventType: Option[String] = None,
+ id: Option[String] = None,
+ retry: Option[Int] = None
+)
+```
+
+The SSE endpoint:
+1. Looks up the widget by alias from user's preferences
+2. Resolves the widget class from registry
+3. Calls widget.updates() to get the update stream
+4. Maps updates to SSE events and streams to client
+5. HTMX SSE extension on frontend receives events and swaps content
+
+Frontend integration (in Dashboard.scala) already has:
+- `hx-ext="sse"` on widget container
+- `sse-connect="/admin/dashboard/widget-stream/{alias}"` for connection
+- `sse-swap="content"` to swap on "content" events
+
+
+ mill summercms.admin.compile succeeds
+ WidgetEventStream.routes compiles
+ SSE encoding produces valid event-stream format
+
+
+ SSE endpoint streams widget updates to connected clients
+ Widgets without updates() receive heartbeat events to keep connection alive
+ ServerSentEvent properly encoded with id, event, data fields
+ X-Accel-Buffering header disables nginx buffering for real-time
+
+
+
+
+
+
+1. `mill summercms.admin.compile` succeeds
+2. Start server and navigate to /admin - dashboard renders with Gridstack
+3. Drag/resize widgets - layout saves after 500ms debounce
+4. SystemStatusWidget updates every 30 seconds via SSE
+5. Add Widget popup shows available widgets
+6. Remove widget removes from layout
+7. Reset restores default layout
+
+
+
+- Dashboard page renders with Gridstack.js grid
+- Widgets display with drag-drop and resize capabilities
+- Layout changes saved to database automatically
+- SSE endpoint streams updates to real-time widgets
+- WelcomeWidget, SystemStatusWidget, QuickActionsWidget functional
+- Add/Remove/Reset widget operations work
+
+
+
+After completion, create `.planning/phases/08-admin-dashboard/08-03-SUMMARY.md`
+
diff --git a/.planning/phases/08-admin-dashboard/08-04-PLAN.md b/.planning/phases/08-admin-dashboard/08-04-PLAN.md
new file mode 100644
index 0000000..bbdbf24
--- /dev/null
+++ b/.planning/phases/08-admin-dashboard/08-04-PLAN.md
@@ -0,0 +1,582 @@
+---
+phase: 08-admin-dashboard
+plan: 04
+type: execute
+wave: 2
+depends_on: ["08-02"]
+files_modified:
+ - summercms/admin/src/settings/SettingsController.scala
+ - summercms/admin/src/settings/SettingsIndex.scala
+autonomous: false
+
+must_haves:
+ truths:
+ - "Admin can navigate to settings index and see categories"
+ - "Admin can open a plugin's settings page"
+ - "Settings form renders from YAML using Phase 7 form system"
+ - "Saving settings persists values to system_settings table"
+ artifacts:
+ - path: "summercms/admin/src/settings/SettingsController.scala"
+ provides: "HTTP routes for settings CRUD"
+ exports: ["SettingsController"]
+ - path: "summercms/admin/src/settings/SettingsIndex.scala"
+ provides: "Settings index page with categories"
+ contains: "def renderSettingsIndex"
+ key_links:
+ - from: "SettingsController"
+ to: "SettingsManager"
+ via: "looks up registered settings"
+ pattern: "settingsManager\\.(listItems|findItem)"
+ - from: "SettingsController"
+ to: "SettingsModelService"
+ via: "loads and saves settings values"
+ pattern: "settingsModelService\\.(get|set)"
+ - from: "SettingsController"
+ to: "FormRenderer"
+ via: "renders form from YAML using Phase 7 system"
+ pattern: "formRenderer\\.render"
+---
+
+
+Create the settings controller with generic CRUD operations and settings index page. Settings pages use the Phase 7 YAML-driven form system.
+
+Purpose: Enable plugins to have settings pages accessible from admin menu
+Output: SettingsController, SettingsIndex rendering, integration with Phase 7 forms
+
+
+
+@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
+@/home/jin/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/phases/08-admin-dashboard/08-RESEARCH.md
+
+# Depends on 08-02 output
+@summercms/admin/src/settings/SettingsManager.scala
+@summercms/admin/src/settings/SettingsModel.scala
+
+# Phase 7 form system (assumed available)
+# @summercms/admin/src/forms/FormRenderer.scala
+
+
+
+
+
+ Task 1: Settings Index Page
+
+ summercms/admin/src/settings/SettingsIndex.scala
+
+
+Create the settings index page that displays all registered settings grouped by category:
+
+**SettingsIndex.scala** - Settings landing page:
+
+```scala
+package summercms.admin.settings
+
+import scalatags.Text.all._
+
+object SettingsIndex:
+ /** Render settings index with categories and items */
+ def renderSettingsIndex(
+ itemsByCategory: Map[String, List[SettingsItem]],
+ userPermissions: Set[String]
+ ): Frag =
+ div(cls := "settings-index",
+ h2(cls := "mb-4", "System Settings"),
+
+ // Category navigation tabs
+ ul(cls := "nav nav-tabs mb-4", id := "settings-tabs", role := "tablist",
+ sortedCategories(itemsByCategory.keys).zipWithIndex.map { case (catCode, idx) =>
+ val category = SettingsCategory.byCode(catCode).getOrElse(
+ CategoryDef(catCode, catCode.capitalize, 999)
+ )
+ li(cls := "nav-item", role := "presentation",
+ button(
+ cls := s"nav-link ${if idx == 0 then "active" else ""}",
+ id := s"$catCode-tab",
+ attr("data-bs-toggle") := "tab",
+ attr("data-bs-target") := s"#$catCode-pane",
+ `type` := "button",
+ role := "tab",
+ category.label
+ )
+ )
+ }
+ ),
+
+ // Category content panes
+ div(cls := "tab-content", id := "settings-content",
+ sortedCategories(itemsByCategory.keys).zipWithIndex.map { case (catCode, idx) =>
+ val items = itemsByCategory.getOrElse(catCode, Nil)
+ val filteredItems = items.filter { item =>
+ item.permissions.isEmpty || item.permissions.exists(userPermissions.contains)
+ }
+
+ div(
+ cls := s"tab-pane fade ${if idx == 0 then "show active" else ""}",
+ id := s"$catCode-pane",
+ role := "tabpanel",
+ attr("aria-labelledby") := s"$catCode-tab",
+
+ if filteredItems.isEmpty then
+ p(cls := "text-muted", "No settings available in this category.")
+ else
+ div(cls := "row g-3",
+ filteredItems.map(renderSettingsCard)
+ )
+ )
+ }
+ )
+ )
+
+ /** Render a single settings item card */
+ private def renderSettingsCard(item: SettingsItem): Frag =
+ div(cls := "col-md-6 col-lg-4",
+ div(cls := "card h-100 settings-card",
+ div(cls := "card-body",
+ div(cls := "d-flex align-items-start",
+ item.icon.map(iconClass =>
+ div(cls := "settings-icon me-3",
+ i(cls := s"$iconClass fs-3 text-muted")
+ )
+ ).getOrElse(frag()),
+ div(cls := "flex-grow-1",
+ h5(cls := "card-title mb-1",
+ a(href := item.url.getOrElse("#"), item.label)
+ ),
+ item.description.map(desc =>
+ p(cls := "card-text text-muted small mb-0", desc)
+ ).getOrElse(frag())
+ )
+ )
+ ),
+ div(cls := "card-footer bg-transparent",
+ a(href := item.url.getOrElse("#"), cls := "btn btn-sm btn-outline-primary",
+ "Configure"
+ )
+ )
+ )
+ )
+
+ /** Sort categories by their defined order */
+ private def sortedCategories(codes: Iterable[String]): List[String] =
+ codes.toList.sortBy { code =>
+ SettingsCategory.byCode(code).map(_.order).getOrElse(999)
+ }
+
+ /** Render settings form page */
+ def renderSettingsForm(
+ item: SettingsItem,
+ formHtml: Frag,
+ backUrl: String = "/admin/system/settings"
+ ): Frag =
+ div(cls := "settings-form",
+ // Breadcrumb
+ nav(attr("aria-label") := "breadcrumb",
+ ol(cls := "breadcrumb",
+ li(cls := "breadcrumb-item", a(href := "/admin", "Dashboard")),
+ li(cls := "breadcrumb-item", a(href := backUrl, "Settings")),
+ li(cls := "breadcrumb-item active", attr("aria-current") := "page", item.label)
+ )
+ ),
+
+ // Header
+ div(cls := "d-flex justify-content-between align-items-center mb-4",
+ div(
+ h2(cls := "mb-1", item.label),
+ item.description.map(desc =>
+ p(cls := "text-muted mb-0", desc)
+ ).getOrElse(frag())
+ ),
+ div(
+ a(href := backUrl, cls := "btn btn-secondary me-2",
+ i(cls := "icon-arrow-left me-1"), "Back"
+ )
+ )
+ ),
+
+ // Form
+ form(
+ method := "POST",
+ attr("hx-post") := item.url.getOrElse("#"),
+ attr("hx-target") := "#form-container",
+ attr("hx-swap") := "innerHTML",
+
+ div(id := "form-container",
+ formHtml
+ ),
+
+ // Actions
+ div(cls := "form-actions mt-4 pt-3 border-top",
+ button(`type` := "submit", cls := "btn btn-primary",
+ i(cls := "icon-check me-1"), "Save Settings"
+ ),
+ button(`type` := "button", cls := "btn btn-outline-secondary ms-2",
+ attr("hx-post") := s"${item.url.getOrElse("#")}/reset",
+ attr("hx-confirm") := "Reset settings to defaults?",
+ i(cls := "icon-refresh me-1"), "Reset to Default"
+ )
+ )
+ )
+ )
+
+ /** Render success message after save */
+ def renderSaveSuccess(item: SettingsItem, formHtml: Frag): Frag =
+ frag(
+ div(cls := "alert alert-success alert-dismissible fade show", role := "alert",
+ "Settings saved successfully.",
+ button(`type` := "button", cls := "btn-close", attr("data-bs-dismiss") := "alert")
+ ),
+ formHtml
+ )
+```
+
+The index page:
+- Groups settings by category using tabs
+- Sorts categories by their defined order
+- Filters items by user permissions
+- Each item shows as a card with icon, label, description
+- Clicking "Configure" navigates to the settings form
+
+
+ mill summercms.admin.compile succeeds
+ SettingsIndex.renderSettingsIndex compiles
+ Categories sorted by order when rendered
+
+
+ SettingsIndex renders tabbed view of settings grouped by category
+ Items filtered by user permissions
+ Settings cards show icon, label, description, configure link
+ Form page has breadcrumb, header, form container, save/reset buttons
+
+
+
+
+ Task 2: Settings Controller
+
+ summercms/admin/src/settings/SettingsController.scala
+
+
+Create the controller for settings CRUD operations:
+
+**SettingsController.scala** - HTTP routes for settings:
+
+```scala
+package summercms.admin.settings
+
+import zio._
+import zio.http._
+import io.circe._
+import io.circe.syntax._
+import scalatags.Text.all._
+
+class SettingsController(
+ settingsManager: SettingsManager,
+ settingsModelService: SettingsModelService,
+ formRenderer: FormRenderer // From Phase 7
+):
+ def routes: Routes[Any, Nothing] =
+ Routes(
+ // Settings index
+ Method.GET / "admin" / "system" / "settings" -> handler { (req: Request) =>
+ renderIndex(req)
+ },
+
+ // Settings update page (GET)
+ Method.GET / "admin" / "system" / "settings" / "update" / string("author") / string("plugin") / string("code") ->
+ handler { (author: String, plugin: String, code: String, req: Request) =>
+ showUpdateForm(author, plugin, code, req)
+ },
+
+ // Settings save (POST)
+ Method.POST / "admin" / "system" / "settings" / "update" / string("author") / string("plugin") / string("code") ->
+ handler { (author: String, plugin: String, code: String, req: Request) =>
+ saveSettings(author, plugin, code, req)
+ },
+
+ // Settings reset (POST)
+ Method.POST / "admin" / "system" / "settings" / "update" / string("author") / string("plugin") / string("code") / "reset" ->
+ handler { (author: String, plugin: String, code: String, req: Request) =>
+ resetSettings(author, plugin, code, req)
+ }
+ )
+
+ private def renderIndex(req: Request): ZIO[Any, Nothing, Response] =
+ (for
+ userPerms <- extractUserPermissions(req)
+ items <- settingsManager.listItems
+ // Filter by permissions
+ filteredItems = items.view.mapValues { categoryItems =>
+ categoryItems.filter { item =>
+ item.permissions.isEmpty || item.permissions.exists(userPerms.contains)
+ }
+ }.filterNot(_._2.isEmpty).toMap
+ html = SettingsIndex.renderSettingsIndex(filteredItems, userPerms)
+ yield Response.html(wrapInLayout(html, "Settings"))).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
+ }
+
+ private def showUpdateForm(
+ author: String,
+ plugin: String,
+ code: String,
+ req: Request
+ ): ZIO[Any, Nothing, Response] =
+ val owner = s"$author.$plugin"
+ (for
+ userPerms <- extractUserPermissions(req)
+ item <- settingsManager.findItem(owner, code)
+ .someOrFail(new RuntimeException(s"Settings not found: $owner.$code"))
+
+ // Check permissions
+ _ <- ZIO.when(item.permissions.nonEmpty && !item.permissions.exists(userPerms.contains)) {
+ ZIO.fail(new RuntimeException("Access denied"))
+ }
+
+ // Set context for settings manager
+ _ <- settingsManager.setContext(owner, code)
+
+ // Load settings model
+ model <- loadSettingsModel(item)
+
+ // Get current values
+ values <- settingsModelService.get(model)
+
+ // Parse YAML fields definition and render form
+ fieldsYaml <- loadFieldsYaml(model.settingsFields)
+ formHtml <- formRenderer.render(fieldsYaml, jsonMapToFormValues(values))
+
+ html = SettingsIndex.renderSettingsForm(item, formHtml)
+ yield Response.html(wrapInLayout(html, item.label))).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
+ }
+
+ private def saveSettings(
+ author: String,
+ plugin: String,
+ code: String,
+ req: Request
+ ): ZIO[Any, Nothing, Response] =
+ val owner = s"$author.$plugin"
+ (for
+ userPerms <- extractUserPermissions(req)
+ item <- settingsManager.findItem(owner, code)
+ .someOrFail(new RuntimeException(s"Settings not found: $owner.$code"))
+
+ // Check permissions
+ _ <- ZIO.when(item.permissions.nonEmpty && !item.permissions.exists(userPerms.contains)) {
+ ZIO.fail(new RuntimeException("Access denied"))
+ }
+
+ // Load model
+ model <- loadSettingsModel(item)
+
+ // Parse form data
+ body <- req.body.asString
+ formData = parseFormData(body)
+
+ // Convert to JSON values
+ jsonValues = formData.view.mapValues(stringToJson).toMap
+
+ // Save settings
+ _ <- settingsModelService.set(model, jsonValues)
+
+ // Re-render form with success message
+ fieldsYaml <- loadFieldsYaml(model.settingsFields)
+ currentValues <- settingsModelService.get(model)
+ formHtml <- formRenderer.render(fieldsYaml, jsonMapToFormValues(currentValues))
+
+ // Return updated form with success message (HTMX swap)
+ html = SettingsIndex.renderSaveSuccess(item, formHtml)
+ yield Response.html(html.render)).catchAll { e =>
+ ZIO.succeed(
+ Response.html(
+ div(cls := "alert alert-danger", s"Error saving settings: ${e.getMessage}").render
+ ).status(Status.BadRequest)
+ )
+ }
+
+ private def resetSettings(
+ author: String,
+ plugin: String,
+ code: String,
+ req: Request
+ ): ZIO[Any, Nothing, Response] =
+ val owner = s"$author.$plugin"
+ (for
+ item <- settingsManager.findItem(owner, code)
+ .someOrFail(new RuntimeException(s"Settings not found: $owner.$code"))
+ model <- loadSettingsModel(item)
+ _ <- settingsModelService.resetDefault(model)
+ yield Response.redirect(URL.decode(item.url.getOrElse("/admin/system/settings")).toOption.get)
+ ).catchAll { e =>
+ ZIO.succeed(Response.text(s"Error: ${e.getMessage}").status(Status.InternalServerError))
+ }
+
+ // Helper methods
+
+ private def extractUserPermissions(req: Request): Task[Set[String]] =
+ // TODO: Extract from session/JWT via Phase 6 auth
+ ZIO.succeed(Set("system.access_settings", "system.manage_settings"))
+
+ private def loadSettingsModel(item: SettingsItem): Task[SettingsModel] =
+ item.settingsClass match
+ case Some(className) =>
+ ZIO.attempt {
+ val clazz = Class.forName(className)
+ clazz.getDeclaredConstructor().newInstance().asInstanceOf[SettingsModel]
+ }.mapError(e => new RuntimeException(s"Failed to load settings model: $className", e))
+ case None =>
+ // Create anonymous settings model from item
+ ZIO.succeed(new SettingsModel {
+ val settingsCode = s"${item.owner}_${item.code}".replace(".", "_").toLowerCase
+ val settingsFields = s"$$/plugins/${item.owner.replace(".", "/")}/${item.code}/fields.yaml"
+ })
+
+ private def loadFieldsYaml(path: String): Task[FormDefinition] =
+ // TODO: Use actual YAML loading from Phase 7
+ // For now, return minimal form
+ ZIO.succeed(FormDefinition(
+ fields = Map(
+ "placeholder" -> FormField(
+ label = Some("Settings"),
+ `type` = Some("text"),
+ comment = Some(s"Fields loaded from: $path")
+ )
+ )
+ ))
+
+ private def parseFormData(body: String): Map[String, String] =
+ body.split("&").flatMap { pair =>
+ pair.split("=", 2) match
+ case Array(k, v) =>
+ Some(java.net.URLDecoder.decode(k, "UTF-8") -> java.net.URLDecoder.decode(v, "UTF-8"))
+ case _ => None
+ }.toMap
+
+ private def stringToJson(s: String): Json =
+ // Try to parse as number or boolean, fall back to string
+ s.toBooleanOption.map(Json.fromBoolean)
+ .orElse(s.toIntOption.map(Json.fromInt))
+ .orElse(s.toDoubleOption.map(Json.fromDouble).flatten)
+ .getOrElse(Json.fromString(s))
+
+ private def jsonMapToFormValues(m: Map[String, Json]): Map[String, Any] =
+ m.view.mapValues { json =>
+ json.fold(
+ jsonNull = "",
+ jsonBoolean = _.toString,
+ jsonNumber = _.toString,
+ jsonString = identity,
+ jsonArray = _.map(_.noSpaces).mkString(","),
+ jsonObject = _.noSpaces
+ )
+ }.toMap
+
+ private def wrapInLayout(content: Frag, title: String): String =
+ // TODO: Use admin layout from Phase 7
+ s"""
+
+
+ $title - SummerCMS
+
+
+
+
+ ${content.render}
+
+
+
+
+ """
+
+// Temporary placeholder for Phase 7 form types
+case class FormDefinition(fields: Map[String, FormField])
+case class FormField(
+ label: Option[String] = None,
+ `type`: Option[String] = Some("text"),
+ comment: Option[String] = None
+)
+
+// Temporary placeholder for Phase 7 form renderer
+trait FormRenderer:
+ def render(definition: FormDefinition, values: Map[String, Any]): Task[Frag]
+```
+
+Note: This controller has placeholder code for Phase 7 form rendering (`FormRenderer`, `FormDefinition`). The actual integration will use the real types from Phase 7 when both phases are complete.
+
+**Integration points to wire up:**
+1. Replace placeholder `FormRenderer` with actual Phase 7 `FormRenderer`
+2. Replace placeholder `FormDefinition`/`FormField` with Phase 7 YAML types
+3. Replace `loadFieldsYaml` with actual YAML parsing from Phase 7
+4. Replace `extractUserPermissions` with Phase 6 auth integration
+
+
+ mill summercms.admin.compile succeeds
+ SettingsController routes compile
+ GET /admin/system/settings returns index page
+ GET /admin/system/settings/update/author/plugin/code returns form
+
+
+ SettingsController provides index, update (GET/POST), reset routes
+ Index filters items by user permissions
+ Form page loads settings model and renders form
+ Save parses form data and persists to system_settings
+ Reset deletes settings and redirects
+ Placeholder form types ready for Phase 7 integration
+
+
+
+
+
+ Settings management system with:
+ - Settings index page with tabbed categories
+ - Plugin settings registration and lookup
+ - Settings form page using YAML-driven forms (placeholder for Phase 7)
+ - Settings persistence to system_settings table
+ - Reset to defaults functionality
+
+
+ 1. Start the application: `mill summercms.run`
+ 2. Navigate to http://localhost:8080/admin/system/settings
+ 3. Verify: Settings index page shows with category tabs
+ 4. Register a test settings item (manually add to SettingsManager in code)
+ 5. Verify: Test settings item appears in appropriate category
+ 6. Click "Configure" on settings item
+ 7. Verify: Settings form page loads
+ 8. Save the form
+ 9. Verify: Success message displays, values persisted
+ 10. Click "Reset to Default"
+ 11. Verify: Settings reset, redirects to index
+
+ Type "approved" or describe issues found
+
+
+
+
+
+1. `mill summercms.admin.compile` succeeds
+2. Settings index page renders with category tabs
+3. Settings form page loads for registered items
+4. Form submission saves to system_settings table
+5. Reset clears settings and returns to index
+6. Permission filtering works on index page
+
+
+
+- Settings index displays all registered settings grouped by category
+- Category tabs sorted by defined order
+- Settings filtered by user permissions
+- Form page renders with breadcrumb and save/reset actions
+- Form submission persists values via SettingsModelService
+- Reset clears settings from database
+- Ready for integration with Phase 7 form system
+
+
+
+After completion, create `.planning/phases/08-admin-dashboard/08-04-SUMMARY.md`
+