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

+236
View File
@@ -0,0 +1,236 @@
package api
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// board loads the full tree: sections ordered, each with items and urls.
// Three flat queries assembled in memory — the whole board is ~100 rows.
func (s *Server) board() ([]Section, error) {
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`)
if err != nil {
return nil, err
}
defer secRows.Close()
sections := []Section{}
byID := map[string]*Section{}
for secRows.Next() {
var sec Section
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
return nil, err
}
sec.Items = []Item{}
sections = append(sections, sec)
}
for i := range sections {
byID[sections[i].ID] = &sections[i]
}
if err := secRows.Err(); err != nil {
return nil, err
}
itemRows, err := s.db.Query(`SELECT id, section_id, kind, name, icon, position, config FROM items ORDER BY position`)
if err != nil {
return nil, err
}
defer itemRows.Close()
items := []*Item{}
itemByID := map[string]*Item{}
for itemRows.Next() {
var it Item
var cfg sql.NullString
if err := itemRows.Scan(&it.ID, &it.SectionID, &it.Kind, &it.Name, &it.Icon, &it.Position, &cfg); err != nil {
return nil, err
}
if cfg.Valid {
raw := json.RawMessage(cfg.String)
it.Config = &raw
}
it.URLs = []URL{}
items = append(items, &it)
itemByID[it.ID] = &it
}
if err := itemRows.Err(); err != nil {
return nil, err
}
urlRows, err := s.db.Query(`SELECT id, item_id, url, label, position FROM urls ORDER BY position`)
if err != nil {
return nil, err
}
defer urlRows.Close()
for urlRows.Next() {
var u URL
var itemID string
if err := urlRows.Scan(&u.ID, &itemID, &u.URL, &u.Label, &u.Position); err != nil {
return nil, err
}
if it, ok := itemByID[itemID]; ok {
it.URLs = append(it.URLs, u)
}
}
if err := urlRows.Err(); err != nil {
return nil, err
}
for _, it := range items {
if sec, ok := byID[it.SectionID]; ok {
sec.Items = append(sec.Items, *it)
}
}
return sections, nil
}
func (s *Server) listSections(c *gin.Context) {
sections, err := s.board()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, sections)
}
func (s *Server) createSection(c *gin.Context) {
var in struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
var maxPos sql.NullFloat64
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}}
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, sec)
}
func (s *Server) updateSection(c *gin.Context) {
var in struct {
Name *string `json:"name"`
Collapsed *bool `json:"collapsed"`
}
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
if in.Name != nil {
if _, err := s.db.Exec(`UPDATE sections SET name = ? WHERE id = ?`, *in.Name, c.Param("id")); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if in.Collapsed != nil {
if _, err := s.db.Exec(`UPDATE sections SET collapsed = ? WHERE id = ?`, *in.Collapsed, c.Param("id")); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
var sec Section
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed)
if notFound(err) {
fail(c, http.StatusNotFound, "section not found")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec.Items = []Item{}
c.JSON(http.StatusOK, sec)
}
func (s *Server) deleteSection(c *gin.Context) {
res, err := s.db.Exec(`DELETE FROM sections WHERE id = ?`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "section not found")
return
}
c.Status(http.StatusNoContent)
}
func (s *Server) reorderSection(c *gin.Context) {
var in struct {
ID string `json:"id"`
BeforeID *string `json:"beforeId"`
AfterID *string `json:"afterId"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.ID == "" {
fail(c, http.StatusBadRequest, "id required")
return
}
pos, err := s.neighborPosition("sections", in.BeforeID, in.AfterID)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
res, err := s.db.Exec(`UPDATE sections SET position = ? WHERE id = ?`, pos, in.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "section not found")
return
}
var sec Section
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec.Items = []Item{}
c.JSON(http.StatusOK, sec)
}
// neighborPosition resolves the new fractional position between two neighbors.
func (s *Server) neighborPosition(table string, beforeID, afterID *string) (float64, error) {
var before, after *float64
if beforeID != nil {
p, err := s.positionOf(table, *beforeID)
if err != nil {
return 0, err
}
before = &p
}
if afterID != nil {
p, err := s.positionOf(table, *afterID)
if err != nil {
return 0, err
}
after = &p
}
return midpoint(before, after), nil
}
func (s *Server) positionOf(table, id string) (float64, error) {
var p float64
// table is an internal constant (sections|items) — never user input.
err := s.db.QueryRow(`SELECT position FROM `+table+` WHERE id = ?`, id).Scan(&p)
return p, err
}
func ptrOr(v sql.NullFloat64) *float64 {
if v.Valid {
return &v.Float64
}
return nil
}