docs(08): create phase plan

Phase 08: Admin Dashboard
- 4 plan(s) in 2 wave(s)
- 2 parallel (08-01, 08-02), 2 sequential (08-03, 08-04)
- Ready for execution
This commit is contained in:
Jakub Zych
2026-02-05 15:16:30 +01:00
parent e0acf7d93a
commit 5b0928f292
5 changed files with 2313 additions and 4 deletions

View File

@@ -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"
---
<objective>
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
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Dashboard Widget Base Types</name>
<files>
summercms/admin/src/dashboard/DashboardWidget.scala
summercms/admin/src/dashboard/WidgetRegistry.scala
</files>
<action>
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.
</action>
<verify>
mill summercms.admin.compile succeeds
Types DashboardWidget, WidgetRegistry, PropertyDef exist and compile
</verify>
<done>
DashboardWidget trait defines render/updates/defineProperties contract
WidgetRegistry provides register/list/resolve operations with ZIO Ref
All supporting case classes (PropertyDef, WidgetConfig, etc.) defined
</done>
</task>
<task type="auto">
<name>Task 2: Dashboard Preferences Service</name>
<files>
summercms/admin/src/dashboard/DashboardPreference.scala
summercms/admin/src/dashboard/DashboardPreferenceService.scala
summercms/db/src/main/resources/db/migration/V005__dashboard_preferences.sql
</files>
<action>
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.
</action>
<verify>
mill summercms.db.flywayMigrate runs migration V005 successfully
mill summercms.admin.compile succeeds
DashboardPreferenceService compiles with Quill queries
</verify>
<done>
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
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/08-admin-dashboard/08-01-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Settings Manager and Categories</name>
<files>
summercms/admin/src/settings/SettingsManager.scala
summercms/admin/src/settings/categories.scala
</files>
<action>
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).
</action>
<verify>
mill summercms.admin.compile succeeds
SettingsCategory.all contains System, CMS, Users, Mail, Misc
SettingsManager.live layer constructs
</verify>
<done>
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
</done>
</task>
<task type="auto">
<name>Task 2: Settings Model Behavior</name>
<files>
summercms/admin/src/settings/SettingsModel.scala
summercms/db/src/main/resources/db/migration/V006__system_settings.sql
</files>
<action>
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)
)
```
</action>
<verify>
mill summercms.db.flywayMigrate runs migration V006 successfully
mill summercms.admin.compile succeeds
SettingsModelService compiles with get/set/resetDefault
</verify>
<done>
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
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/08-admin-dashboard/08-02-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Dashboard Container and Controller</name>
<files>
summercms/admin/src/dashboard/Dashboard.scala
summercms/admin/src/dashboard/DashboardController.scala
</files>
<action>
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"""<!DOCTYPE html>
<html>
<head>
<title>Dashboard - SummerCMS</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack.min.css" rel="stylesheet">
<style>
.grid-stack-item-content { overflow: hidden; }
.widget-option { cursor: pointer; transition: transform 0.2s; }
.widget-option:hover { transform: scale(1.02); }
</style>
</head>
<body>
<div class="container-fluid py-4">
${content.render}
</div>
<div id="modal-container" class="modal fade"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gridstack@10/dist/gridstack-all.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.4/sse.js"></script>
</body>
</html>"""
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.
</action>
<verify>
mill summercms.admin.compile succeeds
Dashboard.renderDashboard generates HTML with grid-stack classes
DashboardController routes compile
</verify>
<done>
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
</done>
</task>
<task type="auto">
<name>Task 2: Core Widgets</name>
<files>
summercms/admin/src/dashboard/widgets/WelcomeWidget.scala
summercms/admin/src/dashboard/widgets/SystemStatusWidget.scala
summercms/admin/src/dashboard/widgets/QuickActionsWidget.scala
</files>
<action>
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 ()
```
</action>
<verify>
mill summercms.admin.compile succeeds
WelcomeWidget, SystemStatusWidget, QuickActionsWidget all implement DashboardWidget
SystemStatusWidget has updates() returning Some(ZStream)
</verify>
<done>
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
</done>
</task>
<task type="auto">
<name>Task 3: SSE Endpoint for Real-Time Updates</name>
<files>
summercms/admin/src/dashboard/sse/WidgetEventStream.scala
</files>
<action>
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
</action>
<verify>
mill summercms.admin.compile succeeds
WidgetEventStream.routes compiles
SSE encoding produces valid event-stream format
</verify>
<done>
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
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/08-admin-dashboard/08-03-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Settings Index Page</name>
<files>
summercms/admin/src/settings/SettingsIndex.scala
</files>
<action>
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
</action>
<verify>
mill summercms.admin.compile succeeds
SettingsIndex.renderSettingsIndex compiles
Categories sorted by order when rendered
</verify>
<done>
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
</done>
</task>
<task type="auto">
<name>Task 2: Settings Controller</name>
<files>
summercms/admin/src/settings/SettingsController.scala
</files>
<action>
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"""<!DOCTYPE html>
<html>
<head>
<title>$title - SummerCMS</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container-fluid py-4">
${content.render}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
</body>
</html>"""
// 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
</action>
<verify>
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
</verify>
<done>
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
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
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
</what-built>
<how-to-verify>
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
</how-to-verify>
<resume-signal>Type "approved" or describe issues found</resume-signal>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/08-admin-dashboard/08-04-SUMMARY.md`
</output>