feat: phase 1 MVP — sections, services, drag-drop, renderers

Backend: migration 0002 (sections/items/urls/settings), CRUD +
fractional reorder, icon upload, settings, export/import, cached
status pings. Handler tests cover reorder, URL scheme validation,
settings round-trip.

Frontend: shared board store over the generated client, dnd-kit
layer (same-section, cross-section, section reorder persisted),
URL chooser popover, add/edit service dialog, settings dialog,
Bento + Cards renderers behind the shared seam, dark/light theme.

Generated with [Devin](https://devin.ai)

Authored By: TDvorak <[email protected]>
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 20:29:22 +02:00
1 parent b60821350a
commit 6004105038
37 files changed
+4187 -66

No files matched your search

+116 -8
View File
@@ -2,31 +2,139 @@
package api
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Server holds handler dependencies: DB, icon dir, status cache.
type Server struct {
db *sql.DB
iconsDir string
status *statusCache
}
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
func NewRouter(logger *zap.Logger, db *sql.DB) *gin.Engine {
func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
s := &Server{db: db, iconsDir: iconsDir, status: newStatusCache(60 * time.Second)}
r := gin.New()
r.Use(accessLog(logger), gin.Recovery())
v1 := r.Group("/api")
v1.GET("/healthz", func(c *gin.Context) {
if err := db.PingContext(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
v1.GET("/healthz", s.healthz)
v1.GET("/sections", s.listSections)
v1.POST("/sections", s.createSection)
v1.PATCH("/sections/:id", s.updateSection)
v1.DELETE("/sections/:id", s.deleteSection)
v1.POST("/sections/reorder", s.reorderSection)
v1.POST("/items", s.createItem)
v1.PATCH("/items/:id", s.updateItem)
v1.DELETE("/items/:id", s.deleteItem)
v1.POST("/items/reorder", s.reorderItem)
v1.POST("/items/:id/icon", s.uploadIcon)
v1.GET("/icons/:file", s.getIcon)
v1.GET("/status", s.getStatus)
v1.GET("/settings", s.getSettings)
v1.PUT("/settings", s.putSettings)
v1.GET("/export", s.exportBoard)
v1.POST("/import", s.importBoard)
return r
}
// ---------- shared types (mirror openapi.yaml) ----------
// URL is one launch target of an item (local, external, custom label).
type URL struct {
ID string `json:"id"`
URL string `json:"url"`
Label string `json:"label"`
Position float64 `json:"position"`
}
// URLInput is a URL as accepted on create/update (id/position assigned).
type URLInput struct {
URL string `json:"url"`
Label string `json:"label"`
}
// Item is a board cell: a service tile today, a widget in Phase 2.
type Item struct {
ID string `json:"id"`
SectionID string `json:"sectionId"`
Kind string `json:"kind"`
Name string `json:"name"`
Icon string `json:"icon"`
Position float64 `json:"position"`
Config *json.RawMessage `json:"config"`
URLs []URL `json:"urls"`
}
// Section is a named, collapsible group of items.
type Section struct {
ID string `json:"id"`
Name string `json:"name"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed"`
Items []Item `json:"items"`
}
// ---------- helpers ----------
func newID(prefix string) string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return prefix + "_" + hex.EncodeToString(b)
}
// midpoint returns a fractional position between two optional neighbors.
func midpoint(before, after *float64) float64 {
switch {
case before == nil && after == nil:
return 1024
case before == nil:
return *after - 1024
case after == nil:
return *before + 1024
default:
return (*before + *after) / 2
}
}
func fail(c *gin.Context, code int, msg string) {
c.AbortWithStatusJSON(code, gin.H{"error": msg})
}
// validURL enforces http(s) launch targets — also keeps /api/status safe
// from file:// or internal-scheme abuse.
func validURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func notFound(err error) bool { return errors.Is(err, sql.ErrNoRows) }
// healthz reports liveness and DB reachability.
func (s *Server) healthz(c *gin.Context) {
if err := s.db.PingContext(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// accessLog is gin.Logger backed by zap: one structured line per request.
func accessLog(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {