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:
@@ -148,11 +148,13 @@ Plans:
|
|||||||
2. Dashboard displays widgets from plugins (reports, stats, quick actions)
|
2. Dashboard displays widgets from plugins (reports, stats, quick actions)
|
||||||
3. Plugins can register settings pages accessible from admin menu
|
3. Plugins can register settings pages accessible from admin menu
|
||||||
4. Settings use the same YAML-driven form system
|
4. Settings use the same YAML-driven form system
|
||||||
**Plans**: TBD
|
**Plans**: 4 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 08-01: Dashboard framework and widget system
|
- [ ] 08-01-PLAN.md - Dashboard widget framework (DashboardWidget trait, WidgetRegistry, DashboardPreferenceService)
|
||||||
- [ ] 08-02: Plugin settings pages
|
- [ ] 08-02-PLAN.md - Plugin settings pages (SettingsManager, SettingsModel, system_settings table)
|
||||||
|
- [ ] 08-03-PLAN.md - Dashboard container and core widgets (Gridstack.js, SSE updates, Welcome/Status/QuickActions)
|
||||||
|
- [ ] 08-04-PLAN.md - Settings controller integration (SettingsController, SettingsIndex, Phase 7 form integration)
|
||||||
|
|
||||||
### Phase 9: Content Management
|
### Phase 9: Content Management
|
||||||
**Goal**: Manage CMS pages, layouts, media, and navigation
|
**Goal**: Manage CMS pages, layouts, media, and navigation
|
||||||
@@ -213,7 +215,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
|
|||||||
| 5. CLI Scaffolding | 0/2 | Planned | - |
|
| 5. CLI Scaffolding | 0/2 | Planned | - |
|
||||||
| 6. Backend Authentication | 0/3 | Planned | - |
|
| 6. Backend Authentication | 0/3 | Planned | - |
|
||||||
| 7. Admin Forms & Lists | 0/3 | Planned | - |
|
| 7. Admin Forms & Lists | 0/3 | Planned | - |
|
||||||
| 8. Admin Dashboard | 0/2 | Not started | - |
|
| 8. Admin Dashboard | 0/4 | Planned | - |
|
||||||
| 9. Content Management | 0/5 | Not started | - |
|
| 9. Content Management | 0/5 | Not started | - |
|
||||||
| 10. Core Plugins | 0/4 | Not started | - |
|
| 10. Core Plugins | 0/4 | Not started | - |
|
||||||
|
|
||||||
|
|||||||
437
.planning/phases/08-admin-dashboard/08-01-PLAN.md
Normal file
437
.planning/phases/08-admin-dashboard/08-01-PLAN.md
Normal 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>
|
||||||
426
.planning/phases/08-admin-dashboard/08-02-PLAN.md
Normal file
426
.planning/phases/08-admin-dashboard/08-02-PLAN.md
Normal 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>
|
||||||
862
.planning/phases/08-admin-dashboard/08-03-PLAN.md
Normal file
862
.planning/phases/08-admin-dashboard/08-03-PLAN.md
Normal 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>
|
||||||
582
.planning/phases/08-admin-dashboard/08-04-PLAN.md
Normal file
582
.planning/phases/08-admin-dashboard/08-04-PLAN.md
Normal 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>
|
||||||
Reference in New Issue
Block a user