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` +