# Phase 8: Admin Dashboard - Research **Researched:** 2026-02-05 **Domain:** Admin dashboard with customizable widgets, real-time updates, and plugin settings pages **Confidence:** HIGH (based on WinterCMS reference + official library documentation + CONTEXT.md decisions) ## Summary This research covers the implementation of a customizable admin dashboard for SummerCMS. The dashboard provides a container for plugin-registered widgets (report widgets) with per-user layout customization, real-time updates via SSE/WebSocket, and a centralized settings system for plugin configuration. The WinterCMS reference implementation provides a comprehensive blueprint: 1. **ReportContainer** widget hosts multiple ReportWidgetBase instances with YAML-configured defaults 2. **Per-user preferences** stored in a dedicated table with JSON-serialized layouts 3. **WidgetManager** singleton collects widgets from plugin registrations 4. **SettingsManager** centralizes plugin settings with categories and permissions 5. **SettingsModel** behavior provides YAML-driven forms for plugin configuration The CONTEXT.md decisions specify: - Masonry/fluid layout with drag-and-drop customization - Real-time updates via WebSocket/SSE for live data - Widget configuration (title, sizing, custom parameters) - WinterCMS-style centralized Settings area grouped by category **Primary recommendation:** Use Gridstack.js for dashboard grid layout (masonry, drag-and-drop, resize), ZIO HTTP SSE for real-time widget updates, HTMX SSE extension for frontend integration, and the Phase 7 YAML-driven form system for widget configuration and settings pages. ## Standard Stack ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Gridstack.js | 10.x | Dashboard grid layout | Native drag-drop, resize, save/restore, TypeScript, no deps | | ZIO HTTP | 3.4.x | SSE/WebSocket endpoints | Already in stack, native streaming support | | HTMX SSE Extension | 2.2.x | Frontend SSE integration | Declarative, pairs with existing HTMX usage | | circe-yaml | 1.x | Widget/settings config | Already in stack from Phase 7 | | ScalaTags | 0.13.x | HTML generation | Already in stack from Phase 7 | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | Chart.js | 4.x | Widget charts/graphs | Stats widgets with visualizations | | Flatpickr | 4.6.x | Date range picker | Widget date configuration | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | Gridstack.js | Muuri | Muuri more flexible but Gridstack specialized for dashboards | | Gridstack.js | React Grid Layout | React Grid requires React; we use HTMX/ScalaTags | | SSE | Full WebSocket | SSE simpler, unidirectional is sufficient for dashboard updates | | Gridstack.js | Packery/Isotope | Packery older, Gridstack actively maintained with TypeScript | **Mill dependencies:** ```scala // Phase 7 deps already included (circe-yaml, scalatags, etc.) // ZIO HTTP already in stack ``` **Frontend (included via CDN or bundled):** ```html ``` ## Architecture Patterns ### Recommended Project Structure ``` summercms/ ├── admin/ │ ├── dashboard/ │ │ ├── Dashboard.scala # Dashboard container │ │ ├── DashboardWidget.scala # Widget base trait/class │ │ ├── WidgetRegistry.scala # Collects registered widgets │ │ ├── UserDashboardPrefs.scala # Per-user layout storage │ │ ├── widgets/ # Core widgets │ │ │ ├── WelcomeWidget.scala │ │ │ ├── SystemStatusWidget.scala │ │ │ └── QuickActionsWidget.scala │ │ └── sse/ │ │ └── WidgetEventStream.scala # SSE endpoint for real-time updates │ ├── settings/ │ │ ├── SettingsManager.scala # Collects registered settings │ │ ├── SettingsController.scala # Generic settings CRUD │ │ ├── SettingsModel.scala # Base trait for settings models │ │ └── categories.scala # Standard category constants │ └── controllers/ │ └── IndexController.scala # Dashboard route handler ``` ### Pattern 1: Dashboard Widget Base Trait **What:** Define a base trait for all dashboard (report) widgets **When to use:** All plugins extending dashboard with widgets **Example:** ```scala // Source: WinterCMS ReportWidgetBase pattern + CONTEXT.md import scalatags.Text.all._ import zio._ trait DashboardWidget: /** Unique widget identifier */ def widgetId: String /** Default alias for this widget */ def defaultAlias: String = widgetId /** Widget display name (translation key) */ def name: String /** Widget description (translation key) */ def description: String /** Define configurable properties */ def defineProperties: Map[String, PropertyDef] = Map( "title" -> PropertyDef( title = "backend::lang.dashboard.widget_title_label", `type` = PropertyType.String, default = Some(name) ), "ocWidgetWidth" -> PropertyDef( title = "backend::lang.dashboard.widget_columns_label", `type` = PropertyType.Dropdown, options = (1 to 12).map(i => i.toString -> s"$i columns").toMap, default = Some("6") ) ) /** Render widget content (can be ZIO effect for async data loading) */ 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 case class PropertyDef( title: String, `type`: PropertyType, description: Option[String] = None, default: Option[String] = None, options: Map[String, String] = Map.empty, required: Boolean = false ) enum PropertyType: case String, Number, Checkbox, Dropdown, DateRange case class WidgetConfig( alias: String, properties: Map[String, Any], position: WidgetPosition ) case class WidgetPosition( x: Int, y: Int, // Grid position w: Int, h: Int // Width and height in grid units ) case class WidgetUpdate( widgetAlias: String, content: Frag ) ``` ### Pattern 2: Widget Registry with Plugin Registration **What:** Singleton registry collecting widgets from plugins during boot **When to use:** Plugin system collects all available dashboard widgets **Example:** ```scala // Source: WinterCMS WidgetManager pattern import zio._ trait WidgetRegistry: def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit] def listWidgets: UIO[Map[String, WidgetInfo]] def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]] def resolveWidget(className: String): UIO[Option[DashboardWidget]] case class WidgetInfo( name: String, description: String, context: List[String] = List("dashboard"), // Where widget can appear permissions: List[String] = Nil // Required permissions ) object WidgetRegistry: val live: ZLayer[Any, Nothing, WidgetRegistry] = ZLayer.fromZIO { Ref.make(Map.empty[String, (WidgetInfo, DashboardWidget)]).map { widgetsRef => new WidgetRegistry: def registerWidget(widgetClass: Class[? <: DashboardWidget], info: WidgetInfo): UIO[Unit] = for instance <- ZIO.succeed(widgetClass.getDeclaredConstructor().newInstance()) _ <- widgetsRef.update(_ + (widgetClass.getName -> (info, instance))) yield () def listWidgets: UIO[Map[String, WidgetInfo]] = widgetsRef.get.map(_.view.mapValues(_._1).toMap) def listWidgetsForContext(context: String): UIO[Map[String, WidgetInfo]] = listWidgets.map(_.filter(_._2.context.contains(context))) def resolveWidget(className: String): UIO[Option[DashboardWidget]] = widgetsRef.get.map(_.get(className).map(_._2)) } } // Plugin registration in boot def registerReportWidgets: Map[Class[? <: DashboardWidget], WidgetInfo] = Map( classOf[SystemStatusWidget] -> WidgetInfo( name = "system::lang.dashboard.status.name", description = "system::lang.dashboard.status.description", permissions = List("system.access_dashboard") ), classOf[WelcomeWidget] -> WidgetInfo( name = "backend::lang.dashboard.welcome.name", description = "backend::lang.dashboard.welcome.description" ) ) ``` ### Pattern 3: Per-User Dashboard Preferences **What:** Store and retrieve per-user widget layouts using database **When to use:** Dashboard layout customization per admin user **Example:** ```scala // Source: WinterCMS UserPreference + ReportContainer patterns import io.getquill._ import zio._ case class DashboardPreference( userId: Long, context: String, // "dashboard", "analytics", etc. widgets: Json // JSON array of widget configurations ) case class StoredWidgetConfig( className: String, alias: String, sortOrder: Int, configuration: Map[String, Any], // Including position: {x, y, w, h} ) 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: def live(quill: Quill.Postgres[SnakeCase]): ZLayer[Any, Nothing, DashboardPreferenceService] = ZLayer.succeed { import quill._ new DashboardPreferenceService: def getPreferences(userId: Long, context: String): Task[List[StoredWidgetConfig]] = // First try user preferences, fall back to system defaults run( query[DashboardPreference] .filter(p => p.userId == lift(userId) && p.context == lift(context)) ).flatMap { case Nil => getDefaultLayout(context) case prefs => ZIO.succeed(parseWidgets(prefs.head.widgets)) } def setPreferences(userId: Long, context: String, widgets: List[StoredWidgetConfig]): Task[Unit] = run( query[DashboardPreference] .filter(p => p.userId == lift(userId) && p.context == lift(context)) .updateValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets)))) ).unit.catchAll { _ => run( query[DashboardPreference] .insertValue(lift(DashboardPreference(userId, context, encodeWidgets(widgets)))) ).unit } 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]] = // Load from YAML config or system parameter ZIO.attempt { val config = loadYamlConfig(s"config_dashboard_$context.yaml") parseDefaultWidgets(config) } } ``` ### Pattern 4: Dashboard Container with Gridstack Integration **What:** Render dashboard with Gridstack.js grid layout **When to use:** Main dashboard view rendering **Example:** ```scala // Source: WinterCMS ReportContainer + Gridstack.js patterns import scalatags.Text.all._ def renderDashboard( widgets: List[(String, DashboardWidget, WidgetConfig)], canAddAndDelete: Boolean )(using ctx: DashboardContext): Frag = { div(cls := "dashboard-container", // Toolbar div(cls := "dashboard-toolbar", canAddAndDelete.option( button(cls := "btn btn-primary", attr("hx-get") := "/admin/dashboard/add-widget-popup", attr("hx-target") := "#modal-container", "Add Widget" ) ), button(cls := "btn btn-secondary", attr("hx-post") := "/admin/dashboard/reset", attr("hx-confirm") := "Reset dashboard to default layout?", "Reset to Default" ) ), // Grid container - Gridstack.js will initialize this div(cls := "grid-stack", id := "dashboard-grid", widgets.map { case (alias, widget, config) => div(cls := "grid-stack-item", attr("gs-id") := alias, attr("gs-x") := config.position.x.toString, attr("gs-y") := config.position.y.toString, attr("gs-w") := config.position.w.toString, attr("gs-h") := config.position.h.toString, attr("gs-min-w") := "2", attr("gs-min-h") := "2", div(cls := "grid-stack-item-content widget-container", // SSE connection for real-time updates widget.updates(config).map { _ => Seq( attr("hx-ext") := "sse", attr("sse-connect") := s"/admin/dashboard/widget-stream/$alias", attr("sse-swap") := "content" ) }.getOrElse(Nil), // Widget header div(cls := "widget-header", span(cls := "widget-title", config.properties.getOrElse("title", widget.name).toString), div(cls := "widget-controls", button(cls := "btn-widget-config", attr("hx-get") := s"/admin/dashboard/widget-config/$alias", attr("hx-target") := "#modal-container", i(cls := "icon-cog") ), canAddAndDelete.option( button(cls := "btn-widget-remove", attr("hx-post") := s"/admin/dashboard/remove-widget/$alias", attr("hx-confirm") := "Remove this widget?", i(cls := "icon-trash") ) ) ) ), // Widget body (rendered content) div(cls := "widget-body", id := s"widget-content-$alias", // Initial render - will be swapped via SSE for real-time widgets renderWidgetContent(widget, config, ctx) ) ) ) } ), // Gridstack initialization script script(raw(""" document.addEventListener('DOMContentLoaded', function() { var grid = GridStack.init({ cellHeight: 80, margin: 10, float: true, removable: false }); // Save layout on change grid.on('change', function(event, items) { var layout = items.map(function(item) { return { id: 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) } }); }); }); """)) ) } ``` ### Pattern 5: SSE Endpoint for Real-Time Widget Updates **What:** ZIO HTTP SSE endpoint streaming widget updates **When to use:** Widgets requiring live data (stats, activity feeds) **Example:** ```scala // Source: ZIO HTTP SSE documentation + HTMX SSE extension import zio._ import zio.http._ import zio.stream._ object WidgetStreamEndpoint: def routes: Routes[WidgetRegistry & DashboardPreferenceService, Nothing] = Routes( Method.GET / "admin" / "dashboard" / "widget-stream" / string("alias") -> handler { (alias: String, req: Request) => for registry <- ZIO.service[WidgetRegistry] prefs <- ZIO.service[DashboardPreferenceService] userId <- extractUserId(req) configs <- prefs.getPreferences(userId, "dashboard") config <- ZIO.fromOption(configs.find(_.alias == alias)) .orElseFail(Response.notFound) widget <- registry.resolveWidget(config.className) .someOrFail(Response.notFound) stream = widget.updates(toWidgetConfig(config)) match case Some(updateStream) => updateStream.map { update => ServerSentEvent( data = Some(update.content.render), eventType = Some("content"), id = Some(java.util.UUID.randomUUID().toString) ) } case None => // No updates - just keep connection alive with heartbeat 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 ), body = Body.fromCharSequenceStreamChunked( stream.map(sse => sse.encode) ) ) } ) // Example widget with real-time updates class ActiveUsersWidget extends DashboardWidget: def widgetId = "active-users" def name = "backend::lang.dashboard.active_users" def description = "Shows currently active users" def render(config: WidgetConfig, ctx: DashboardContext): Task[Frag] = getActiveUserCount.map { count => div(cls := "stat-widget", span(cls := "stat-value", count.toString), span(cls := "stat-label", "Active Users") ) } override def updates(config: WidgetConfig): Option[ZStream[Any, Nothing, WidgetUpdate]] = Some( ZStream.repeatZIOWithSchedule( getActiveUserCount.map { count => WidgetUpdate( config.alias, div(cls := "stat-widget", span(cls := "stat-value", count.toString), span(cls := "stat-label", "Active Users") ) ) }.orDie, Schedule.fixed(5.seconds) ) ) ``` ### Pattern 6: Settings Manager for Plugin Settings **What:** Centralized registry of plugin settings pages with categories **When to use:** Plugins registering configuration pages **Example:** ```scala // Source: WinterCMS SettingsManager pattern import zio._ // Standard categories (translation keys) object SettingsCategory: val System = "system::lang.system.categories.system" val CMS = "system::lang.system.categories.cms" val Users = "system::lang.system.categories.users" val Mail = "system::lang.system.categories.mail" val Misc = "system::lang.system.categories.misc" case class SettingsItem( code: String, owner: String, // Plugin ID: "golem15.blog" label: String, // Translation key description: Option[String], category: String, // SettingsCategory constant icon: Option[String], url: Option[String], // Custom URL, or auto-generated settingsClass: Option[String], // Settings model class name permissions: List[String], order: Int = 500, keywords: Option[String] = None // For search ) trait SettingsManager: def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit] def listItems(context: Option[String] = None): UIO[Map[String, List[SettingsItem]]] def findItem(owner: String, code: String): UIO[Option[SettingsItem]] def setContext(owner: String, code: String): UIO[Unit] def getContext: UIO[(String, String)] object SettingsManager: val live: ZLayer[Any, Nothing, SettingsManager] = ZLayer.fromZIO { for itemsRef <- Ref.make(Map.empty[String, SettingsItem]) contextRef <- Ref.make(("", "")) yield new SettingsManager: def registerSettings(owner: String, items: Map[String, SettingsItem]): UIO[Unit] = itemsRef.update { current => items.foldLeft(current) { case (acc, (code, item)) => val key = s"${owner.toUpperCase}.$${code.toUpperCase}" val itemWithOwner = item.copy( code = code, owner = owner, url = item.url.orElse(Some(generateSettingsUrl(owner, code))) ) acc + (key -> itemWithOwner) } } def listItems(context: Option[String]): UIO[Map[String, List[SettingsItem]]] = itemsRef.get.map { items => items.values .groupBy(_.category) .view.mapValues(_.toList.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((owner.toLowerCase, code.toLowerCase)) def getContext: UIO[(String, String)] = contextRef.get private def generateSettingsUrl(owner: String, code: String): String = val parts = owner.split('.') s"/admin/system/settings/update/${parts.mkString("/")}/$code" } // Plugin registration def registerSettings: Map[String, SettingsItem] = Map( "blog" -> SettingsItem( code = "blog", owner = "golem15.blog", label = "golem15.blog::lang.settings.label", description = Some("golem15.blog::lang.settings.description"), category = SettingsCategory.CMS, icon = Some("icon-pencil"), settingsClass = Some("golem15.blog.models.Settings"), permissions = List("golem15.blog.manage_settings"), order = 500, keywords = Some("blog post category") ) ) ``` ### Pattern 7: Settings Model Behavior **What:** Model behavior for YAML-driven settings storage **When to use:** Plugin settings models stored in system_settings table **Example:** ```scala // Source: WinterCMS SettingsModel behavior import zio._ import io.circe.Json trait SettingsModel: def settingsCode: String def settingsFields: String // Path to fields.yaml // Mixin implementation private var fieldValues: Map[String, Any] = Map.empty private var instance: Option[SettingsModel] = None /** Get singleton instance (loads from DB or creates default) */ def getInstance: Task[SettingsModel] = instance match case Some(inst) => ZIO.succeed(inst) case None => loadFromDatabase.flatMap { case Some(record) => ZIO.succeed { fieldValues = record.values instance = Some(this) this } case None => ZIO.succeed { initSettingsData() instance = Some(this) this } } /** Override to set default values */ def initSettingsData(): Unit = () /** Get a setting value */ def get[T](key: String, default: T): T = fieldValues.get(key).map(_.asInstanceOf[T]).getOrElse(default) /** Set setting values */ def set(values: Map[String, Any]): Task[Unit] = ZIO.attempt { fieldValues = fieldValues ++ values } *> saveToDatabase /** Reset to defaults */ def resetDefault: Task[Unit] = deleteFromDatabase *> ZIO.succeed { fieldValues = Map.empty instance = None } // Database operations (using system_settings table) private def loadFromDatabase: Task[Option[SettingsRecord]] = ??? private def saveToDatabase: Task[Unit] = ??? private def deleteFromDatabase: Task[Unit] = ??? // Example settings model class BlogSettings extends SettingsModel: val settingsCode = "golem15_blog_settings" val settingsFields = "fields.yaml" override def initSettingsData(): Unit = fieldValues = Map( "posts_per_page" -> 10, "show_author" -> true, "excerpt_length" -> 200 ) // Usage val postsPerPage = BlogSettings.getInstance.flatMap { settings => ZIO.succeed(settings.get("posts_per_page", 10)) } ``` ### Anti-Patterns to Avoid - **Polling for updates:** Use SSE streams instead of periodic AJAX polling - **Global layout storage:** Each user must have their own layout preferences - **Inline widget configuration:** Use YAML-driven property definitions for consistency - **Blocking widget renders:** Widget render should be ZIO Task, not blocking - **Shared mutable state:** Use ZIO Ref for widget registry and settings state - **Direct database queries in widgets:** Use service layer for data access ## Don't Hand-Roll Problems that look simple but have existing solutions: | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Dashboard grid layout | Custom CSS Grid + JS | Gridstack.js | Drag-drop, resize, save/restore, mobile support | | Real-time updates | Custom WebSocket protocol | ZIO HTTP SSE + HTMX ext | Standard protocol, automatic reconnection | | User preferences storage | Session/cookie storage | Database per-user table | Persists across sessions, multi-device | | Widget property forms | Custom form generation | Phase 7 form system | Consistency, YAML-driven, validation | | Settings form generation | Custom forms per plugin | SettingsModel behavior | Standardized, YAML-driven, cached | **Key insight:** Dashboard widgets are deceptively complex. Each widget needs configuration UI, persistence, permissions, and potentially real-time updates. Reuse WinterCMS patterns for the container/widget relationship rather than building ad-hoc. ## Common Pitfalls ### Pitfall 1: SSE Connection Limits **What goes wrong:** Browser limits concurrent SSE connections (typically 6 per domain) **Why it happens:** Each widget opens its own SSE connection **How to avoid:** - Single SSE connection per dashboard page with multiplexed events - Use event names to route updates to correct widgets - Send widget alias in event type: `event: widget-$alias` **Warning signs:** Later widgets don't receive updates; "ERR_CONNECTION_LIMIT" in browser console ### Pitfall 2: Layout Save Race Conditions **What goes wrong:** Rapid drag/resize operations cause lost updates **Why it happens:** Multiple save requests overlap, last one wins **How to avoid:** - Debounce save operations (300-500ms after last change) - Include full layout state in each save (not incremental updates) - Server acknowledges save, client can retry on failure **Warning signs:** Layout reverts unexpectedly; saved layout doesn't match visual ### Pitfall 3: Widget Configuration State Loss **What goes wrong:** Widget config changes lost on page reload **Why it happens:** Config saved to layout but not persisted to database **How to avoid:** - Widget config stored in preferences alongside position - Config changes trigger preferences save immediately - Default config comes from widget definition, overrides from preferences **Warning signs:** "Reset" doesn't restore expected defaults; config resets on browser refresh ### Pitfall 4: Memory Leaks in Real-Time Widgets **What goes wrong:** Dashboard becomes slow/unresponsive over time **Why it happens:** SSE event handlers accumulate; old widget content not garbage collected **How to avoid:** - HTMX's SSE extension manages this automatically via sse-swap - Avoid manual JavaScript event listeners without cleanup - Use `sse-close` attribute for explicit cleanup on navigation **Warning signs:** Growing memory usage; event handlers stack on widget reconfigure ### Pitfall 5: Settings Cache Invalidation **What goes wrong:** Changed settings not reflected immediately **Why it happens:** Settings cached at application level for performance **How to avoid:** - Clear cache on save (WinterCMS calls queue:restart) - Use short TTL for frequently-changed settings - Provide "clear cache" admin action **Warning signs:** Changes require restart; inconsistent behavior across requests ### Pitfall 6: Category Translation and Ordering **What goes wrong:** Settings categories display incorrectly or in wrong order **Why it happens:** Categories are translation keys, not display strings; no explicit ordering **How to avoid:** - Define standard categories with explicit sort order - Translate category keys at render time, not storage - Allow plugins to register new categories with order hints **Warning signs:** Untranslated category keys visible; random category order ## Code Examples ### Complete Dashboard Controller ```scala // Source: WinterCMS backend/controllers/Index.php + Gridstack patterns import zio._ import zio.http._ import scalatags.Text.all._ class DashboardController( widgetRegistry: WidgetRegistry, prefsService: DashboardPreferenceService, auth: AuthService ): def index: Handler[Any, Nothing, Request, Response] = handler { (req: Request) => for user <- auth.getUser(req) widgets <- loadUserDashboard(user.id, "dashboard") html <- renderDashboard(widgets, canAddAndDelete = true) yield Response.html(html) } def saveLayout: Handler[Any, Nothing, Request, Response] = handler { (req: Request) => for user <- auth.getUser(req) layout <- req.body.asString.flatMap(parseLayoutJson) _ <- prefsService.setPreferences(user.id, "dashboard", layout) yield Response.ok } def addWidgetPopup: Handler[Any, Nothing, Request, Response] = handler { (req: Request) => for user <- auth.getUser(req) widgets <- widgetRegistry.listWidgetsForContext("dashboard") // Filter by user permissions available = widgets.filter { case (_, info) => info.permissions.isEmpty || user.hasAnyAccess(info.permissions) } yield Response.html(renderAddWidgetPopup(available)) } def addWidget: Handler[Any, Nothing, Request, Response] = handler { (req: Request) => for user <- auth.getUser(req) form <- req.body.asURLEncodedForm className <- ZIO.fromOption(form.get("className")) size <- ZIO.fromOption(form.get("size").flatMap(_.toIntOption)) widget <- widgetRegistry.resolveWidget(className) .someOrFail(Response.badRequest("Invalid widget class")) // Get current layout to determine new widget position current <- prefsService.getPreferences(user.id, "dashboard") nextOrder = current.map(_.sortOrder).maxOption.getOrElse(0) + 1 alias = s"widget_${user.id}_$nextOrder" newWidget = StoredWidgetConfig( className = className, alias = alias, sortOrder = nextOrder, configuration = Map( "ocWidgetWidth" -> size, "x" -> 0, "y" -> current.size, // Add at bottom "w" -> size, "h" -> 2 ) ) _ <- prefsService.setPreferences(user.id, "dashboard", current :+ newWidget) // Return partial HTML for the new widget config = toWidgetConfig(newWidget) html <- widget.render(config, DashboardContext.forUser(user)) yield Response.html(renderGridstackItem(alias, widget, config, html)) } def removeWidget: Handler[Any, Nothing, Request, Response] = handler { (req: Request) => for user <- auth.getUser(req) alias <- req.url.queryParams.get("alias").flatMap(_.headOption) .fold(ZIO.fail(Response.badRequest("Missing alias")))(ZIO.succeed) current <- prefsService.getPreferences(user.id, "dashboard") updated = current.filterNot(_.alias == alias) _ <- prefsService.setPreferences(user.id, "dashboard", updated) yield Response.ok } def resetDashboard: Handler[Any, Nothing, Request, Response] = handler { (req: Request) => for user <- auth.getUser(req) _ <- prefsService.resetToDefault(user.id, "dashboard") yield Response.redirect("/admin") } ``` ### Settings Controller with Generic CRUD ```scala // Source: WinterCMS system/controllers/Settings.php import zio._ import zio.http._ class SettingsController( settingsManager: SettingsManager, formRenderer: FormRenderer, auth: AuthService ): def index: Handler[Any, Nothing, Request, Response] = handler { (req: Request) => for user <- auth.getUser(req) items <- settingsManager.listItems() // Filter by permissions filtered = items.view.mapValues(_.filter { item => item.permissions.isEmpty || user.hasAnyAccess(item.permissions) }).filterNot(_._2.isEmpty).toMap yield Response.html(renderSettingsIndex(filtered)) } def update(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] = handler { (req: Request) => val owner = s"$author.$plugin" for user <- auth.getUser(req) item <- settingsManager.findItem(owner, code) .someOrFail(Response.notFound) _ <- ZIO.when(item.permissions.nonEmpty && !user.hasAnyAccess(item.permissions)) { ZIO.fail(Response.forbidden) } _ <- settingsManager.setContext(owner, code) // Load settings model model <- loadSettingsModel(item.settingsClass.get) instance <- model.getInstance // Parse YAML fields definition fieldsConfig <- parseFieldsYaml(model.settingsFields) // Render form form <- formRenderer.render(fieldsConfig, instance.getValues) yield Response.html(renderSettingsForm(item, form)) } def save(author: String, plugin: String, code: String): Handler[Any, Nothing, Request, Response] = handler { (req: Request) => val owner = s"$author.$plugin" for user <- auth.getUser(req) item <- settingsManager.findItem(owner, code) .someOrFail(Response.notFound) model <- loadSettingsModel(item.settingsClass.get) instance <- model.getInstance // Parse form data formData <- req.body.asURLEncodedForm values = formData.view.mapValues(_.head).toMap // Validate (optional - use model validation rules) _ <- validateSettings(model, values) // Save _ <- instance.set(values) // Flash success message yield Response.redirect(s"/admin/system/settings/update/$author/$plugin/$code") .addHeader(Header.Custom("X-Flash", "Settings saved successfully")) } ``` ### Default Dashboard Configuration YAML ```yaml # config_dashboard.yaml # Source: WinterCMS backend/controllers/index/config_dashboard.yaml defaultWidgets: welcome: class: summercms.admin.widgets.WelcomeWidget sortOrder: 50 configuration: ocWidgetWidth: 7 x: 0 y: 0 w: 7 h: 3 systemStatus: class: summercms.admin.widgets.SystemStatusWidget sortOrder: 60 configuration: ocWidgetWidth: 5 x: 7 y: 0 w: 5 h: 3 recentActivity: class: summercms.admin.widgets.RecentActivityWidget sortOrder: 70 configuration: ocWidgetWidth: 12 x: 0 y: 3 w: 12 h: 4 itemCount: 10 ``` ### Widget Property Configuration Popup ```scala // Source: WinterCMS ReportContainer property config patterns def renderWidgetConfigPopup( alias: String, widget: DashboardWidget, currentConfig: WidgetConfig ): Frag = { val properties = widget.defineProperties div(cls := "modal-dialog", div(cls := "modal-content", div(cls := "modal-header", h5(cls := "modal-title", "Widget Configuration"), button(`type` := "button", cls := "btn-close", attr("data-bs-dismiss") := "modal") ), form( attr("hx-post") := s"/admin/dashboard/widget-config/$alias", attr("hx-target") := s"#widget-content-$alias", attr("hx-swap") := "innerHTML", div(cls := "modal-body", properties.map { case (propName, propDef) => div(cls := "form-group", label(cls := "form-label", propDef.title), propDef.description.map(d => p(cls := "help-block", d)), renderPropertyInput(propName, propDef, currentConfig.properties.get(propName)) ) } ), div(cls := "modal-footer", button(`type` := "button", cls := "btn btn-secondary", attr("data-bs-dismiss") := "modal", "Cancel"), button(`type` := "submit", cls := "btn btn-primary", "Save") ) ) ) ) } def renderPropertyInput(name: String, propDef: PropertyDef, value: Option[Any]): Frag = { val currentValue = value.orElse(propDef.default).map(_.toString).getOrElse("") propDef.`type` match case PropertyType.String => input(cls := "form-control", `type` := "text", attr("name") := name, attr("value") := currentValue) case PropertyType.Number => input(cls := "form-control", `type` := "number", attr("name") := name, attr("value") := currentValue) case PropertyType.Checkbox => div(cls := "form-check", input(cls := "form-check-input", `type` := "checkbox", attr("name") := name, (currentValue == "true").option(attr("checked") := "checked")) ) case PropertyType.Dropdown => select(cls := "form-select", attr("name") := name, propDef.options.map { case (optValue, optLabel) => option(attr("value") := optValue, (currentValue == optValue).option(attr("selected") := "selected"), optLabel) } ) case PropertyType.DateRange => input(cls := "form-control flatpickr", `type` := "text", attr("name") := name, attr("value") := currentValue, attr("data-mode") := "range") } ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | jQuery UI sortable | Gridstack.js | 2020+ | Better mobile support, TypeScript, maintained | | Polling for updates | SSE/WebSocket | 2018+ | Real-time, lower overhead | | Session-based prefs | Database per-user | Standard | Persists, multi-device | | Manual AJAX widget refresh | HTMX SSE extension | 2023+ | Declarative, automatic reconnection | | PHP serialized layout | JSON layout storage | Standard | Interoperable, queryable | **Deprecated/outdated:** - jQuery UI Sortable: Use Gridstack.js for dashboard grids - Isotope/Packery: Gridstack specifically designed for dashboards - Long-polling: Use SSE for server-push updates - Cookie-based preferences: Use database storage for persistence ## Open Questions 1. **Multiplexed SSE vs Multiple Connections** - What we know: Browser limits concurrent connections (6 per domain) - What's unclear: Best pattern for many real-time widgets - Recommendation: Single SSE connection with event routing by widget alias 2. **Widget Caching Strategy** - What we know: Some widgets expensive to render (database queries) - What's unclear: How to cache widget HTML while maintaining real-time updates - Recommendation: Cache initial render, SSE updates bypass cache 3. **Admin-Set Default Layout** - What we know: WinterCMS allows admins to "make layout default" - What's unclear: Should super-admin override affect existing user layouts? - Recommendation: System default separate from user prefs; "reset" restores system default 4. **Widget Asset Loading** - What we know: Some widgets need additional JS/CSS (Chart.js) - What's unclear: How to lazy-load widget assets only when widget is used - Recommendation: Define asset dependencies in widget class, inject on dashboard load ## Sources ### Primary (HIGH confidence) - WinterCMS reference implementation: - `modules/backend/widgets/ReportContainer.php` - Dashboard container pattern - `modules/backend/classes/ReportWidgetBase.php` - Widget base class - `modules/backend/classes/WidgetManager.php` - Widget registration - `modules/system/classes/SettingsManager.php` - Settings registration - `modules/system/behaviors/SettingsModel.php` - Settings persistence - `modules/backend/models/UserPreference.php` - Per-user storage - [HTMX SSE Extension](https://htmx.org/extensions/sse/) - SSE frontend integration - [Gridstack.js](https://gridstackjs.com/) - Dashboard grid layout library - [ZIO HTTP SSE Examples](https://ziohttp.com/examples/server-sent-events-in-endpoints/) - SSE endpoint implementation ### Secondary (MEDIUM confidence) - [Designing Scalable Database for User Settings](https://medium.com/@jorgemudry/designing-a-scalable-database-for-user-settings-8e189a678a85) - Preferences schema patterns - [Building Real-Time Dashboards with FastAPI and HTMX](https://medium.com/codex/building-real-time-dashboards-with-fastapi-and-htmx-01ea458673cb) - SSE dashboard patterns ### Tertiary (LOW confidence) - WebSearch results on dashboard widget best practices 2025-2026 - Community discussions on SSE vs WebSocket tradeoffs ## Metadata **Confidence breakdown:** - Standard stack: HIGH - Gridstack.js docs + ZIO HTTP SSE verified - Architecture: HIGH - Based on thorough WinterCMS reference analysis - Pitfalls: MEDIUM - Mix of WinterCMS patterns and web development experience - Real-time patterns: MEDIUM - ZIO SSE documented, HTMX integration well-established **Research date:** 2026-02-05 **Valid until:** 2026-03-05 (30 days - stable domain)