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

+64
View File
@@ -0,0 +1,64 @@
package api
import (
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// Allowed icon extensions — whitelist keeps the upload dir free of HTML/JS.
var iconExts = map[string]struct{}{
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {},
".gif": {}, ".svg": {}, ".ico": {}, ".avif": {},
}
// uploadIcon stores a multipart file under iconsDir as <itemID><ext> and
// points the item's icon field at /api/icons/<file>.
func (s *Server) uploadIcon(c *gin.Context) {
id := c.Param("id")
var exists int
if err := s.db.QueryRow(`SELECT 1 FROM items WHERE id = ?`, id).Scan(&exists); err != nil {
fail(c, http.StatusNotFound, "item not found")
return
}
fh, err := c.FormFile("file")
if err != nil {
fail(c, http.StatusBadRequest, "file field required")
return
}
if fh.Size > 4<<20 {
fail(c, http.StatusBadRequest, "icon too large (max 4 MB)")
return
}
ext := strings.ToLower(filepath.Ext(fh.Filename))
if _, ok := iconExts[ext]; !ok {
fail(c, http.StatusBadRequest, "unsupported icon type")
return
}
name := id + ext
if err := c.SaveUploadedFile(fh, filepath.Join(s.iconsDir, name)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if _, err := s.db.Exec(`UPDATE items SET icon = ? WHERE id = ?`, "/api/icons/"+name, id); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
it, err := s.loadItem(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, it)
}
// getIcon serves a stored icon. filepath.Base strips any traversal before
// the path is pinned inside iconsDir.
func (s *Server) getIcon(c *gin.Context) {
name := filepath.Base(c.Param("file"))
http.ServeFile(c.Writer, c.Request, filepath.Join(s.iconsDir, name))
}