mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 15:51:27 +00:00
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:
1 parent
b60821350a
commit
6004105038
37 files changed
+4187
-66
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user