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

+3
View File
@@ -20,3 +20,6 @@ data/*
# misc
.DS_Store
coverage/
# local tooling artifacts
.playwright-mcp/
+14 -14
View File
@@ -54,24 +54,24 @@ Repo hygiene and pipelines before features.
**Exit:** `docker compose up` serves a blank board — verified locally (33.6 MB image). CI green on `main` — pending first push.
## Phase 1 — MVP → v0.1.0
## Phase 1 — MVP → v0.1.0 ✅ done 2026-09-17
The product's spine: sections, services, drag-drop, multi-URL.
- [ ] Schema: `sections`, `items`, `urls`, `settings` (fractional `position REAL` ordering)
- [ ] REST API: CRUD for sections/items/urls, `reorder` endpoints, icon upload, `settings`, `export`/`import` JSON
- [ ] Renderer contract: shared board store + `BoardView`/`SectionView`/`ItemView`/`WidgetView` seam, dnd + popover as shared primitives
- [ ] **Bento** renderer (default): mixed-size grid, hairline section headers, tiles with status chips
- [ ] **Cards** renderer: uniform compact grid — second renderer proves the seam while code is small
- [ ] dnd-kit: reorder items in a section, move items between sections, reorder sections; persist on drop
- [ ] Collapse/expand sections (persisted)
- [ ] `UrlPopover`: 1 URL → direct open, 2+ → chooser (`local`/`external`/custom tags)
- [ ] `AddServiceDialog` / `EditServiceDialog` (name, URLs+labels, icon URL or file upload)
- [ ] Icon handling: upload to `data/icons/`, remote URL passthrough, letter-tile fallback
- [ ] Dark/light toggle (persisted), renderer picker in settings, responsive to ~360px
- [ ] Status ping: server-side HEAD request per service URL, cached ~60s → `up`/`down` chip
- [x] Schema: `sections`, `items`, `urls`, `settings` (fractional `position REAL` ordering)
- [x] REST API: CRUD for sections/items/urls, `reorder` endpoints, icon upload, `settings`, `export`/`import` JSON
- [x] Renderer contract: shared board store + `BoardView`/`SectionView`/`ItemView`/`WidgetView` seam, dnd + popover as shared primitives
- [x] **Bento** renderer (default): mixed-size grid, hairline section headers, tiles with status chips
- [x] **Cards** renderer: uniform compact grid — second renderer proves the seam while code is small
- [x] dnd-kit: reorder items in a section, move items between sections, reorder sections; persist on drop
- [x] Collapse/expand sections (persisted)
- [x] `UrlPopover`: 1 URL → direct open, 2+ → chooser (`local`/`external`/custom tags)
- [x] `AddServiceDialog` / `EditServiceDialog` (name, URLs+labels, icon URL or file upload)
- [x] Icon handling: upload to `data/icons/`, remote URL passthrough, letter-tile fallback
- [x] Dark/light toggle (persisted), renderer picker in settings, responsive to ~360px
- [x] Status ping: server-side HEAD request per service URL, cached ~60s → `up`/`down` chip
**Exit:** create sections, add services with icons and dual URLs, drag everything, reload — state persists. `docker run` single image works. Export/import round-trips.
**Exit:** create sections, add services with icons and dual URLs, drag everything, reload — state persists. `docker run` single image works. Export/import round-trips. — verified: full API + browser smoke (dnd, popover, upload, collapse, themes, both renderers, 360px), `api_test.go` covers reorder/URL-validation/settings.
## Phase 2 — Widgets → v0.2.0
+2 -1
View File
@@ -42,7 +42,8 @@ func main() {
}
defer func() { _ = sqlDB.Close() }()
router := api.NewRouter(logger, sqlDB)
iconsDir := filepath.Join(dataDir, "icons")
router := api.NewRouter(logger, sqlDB, iconsDir)
if err := web.Register(router); err != nil {
logger.Fatal("register embedded UI", zap.Error(err))
}
+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) {
+152
View File
@@ -0,0 +1,152 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/db"
"go.uber.org/zap"
)
func testServer(t *testing.T) *httptest.Server {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
gin.SetMode(gin.TestMode)
return httptest.NewServer(NewRouter(zap.NewNop(), d, t.TempDir()))
}
func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) {
t.Helper()
req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
defer res.Body.Close()
var out map[string]any
_ = json.NewDecoder(res.Body).Decode(&out)
return res, out
}
func TestSectionItemReorderFlow(t *testing.T) {
srv := testServer(t)
defer srv.Close()
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
_, b := do(t, "POST", srv.URL+"/api/sections", `{"name":"B"}`)
secA, secB := a["id"].(string), b["id"].(string)
// Move B before A: position should go below A's.
res, mv := do(t, "POST", srv.URL+"/api/sections/reorder", `{"id":"`+secB+`","afterId":"`+secA+`"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("reorder: %v", mv)
}
if mv["position"].(float64) >= a["position"].(float64) {
t.Fatalf("expected B position < A position, got %v vs %v", mv["position"], a["position"])
}
// Item with two urls into A.
_, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+secA+`","name":"Svc","urls":[{"url":"http://x.local","label":"local"},{"url":"https://x.dev","label":"external"}]}`)
if res := it["id"]; res == nil {
t.Fatalf("create item: %v", it)
}
// Move the item across sections.
res, mv = do(t, "POST", srv.URL+"/api/items/reorder", `{"id":"`+it["id"].(string)+`","sectionId":"`+secB+`"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("item reorder: %v", mv)
}
// Board reflects the move.
var secs []Section
resp, err := http.Get(srv.URL + "/api/sections")
if err != nil {
t.Fatalf("get sections: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
t.Fatalf("decode board: %v", err)
}
if len(secs) != 2 || secs[0].Name != "B" || secs[1].Name != "A" {
t.Fatalf("unexpected section order: %+v", secs)
}
if len(secs[0].Items) != 1 || secs[0].Items[0].Name != "Svc" {
t.Fatalf("item did not move to section B: %+v", secs[0].Items)
}
if len(secs[0].Items[0].URLs) != 2 {
t.Fatalf("expected 2 urls, got %+v", secs[0].Items[0].URLs)
}
}
func TestRejectsNonHTTPURLs(t *testing.T) {
srv := testServer(t)
defer srv.Close()
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
res, body := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Bad","urls":[{"url":"file:///etc/passwd","label":"x"}]}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for file:// url, got %d: %v", res.StatusCode, body)
}
}
func TestSettingsRoundTrip(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"theme":"light","renderer":"cards"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("put settings: %d", res.StatusCode)
}
resp, err := http.Get(srv.URL + "/api/settings")
if err != nil {
t.Fatalf("get settings: %v", err)
}
defer resp.Body.Close()
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode settings: %v", err)
}
if got["theme"] != "light" || got["renderer"] != "cards" {
t.Fatalf("settings mismatch: %v", got)
}
}
func TestMidpoint(t *testing.T) {
f := func(v float64) *float64 { return &v }
if got := midpoint(nil, nil); got != 1024 {
t.Fatalf("empty midpoint: %v", got)
}
if got := midpoint(nil, f(1024)); got != 0 {
t.Fatalf("before-only midpoint: %v", got)
}
if got := midpoint(f(1024), nil); got != 2048 {
t.Fatalf("after-only midpoint: %v", got)
}
if got := midpoint(f(0), f(1024)); got != 512 {
t.Fatalf("bounded midpoint: %v", got)
}
}
func TestValidURL(t *testing.T) {
for _, u := range []string{"http://a.b", "https://a.b:8443/x"} {
if !validURL(u) {
t.Fatalf("expected valid: %s", u)
}
}
for _, u := range []string{"file:///etc/passwd", "javascript:alert(1)", "http://", "notaurl", ""} {
if validURL(u) {
t.Fatalf("expected invalid: %s", u)
}
}
}
+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
}
+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))
}
+227
View File
@@ -0,0 +1,227 @@
package api
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
type itemInput struct {
SectionID string `json:"sectionId"`
Name string `json:"name"`
Icon string `json:"icon"`
URLs []URLInput `json:"urls"`
}
func (s *Server) createItem(c *gin.Context) {
var in itemInput
if err := c.ShouldBindJSON(&in); err != nil || in.SectionID == "" || in.Name == "" {
fail(c, http.StatusBadRequest, "sectionId and name required")
return
}
for _, u := range in.URLs {
if !validURL(u.URL) {
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
return
}
}
var maxPos sql.NullFloat64
err := s.db.QueryRow(`SELECT MAX(position) FROM items WHERE section_id = ?`, in.SectionID).Scan(&maxPos)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
it := Item{ID: newID("i"), SectionID: in.SectionID, Kind: "service", Name: in.Name, Icon: in.Icon,
Position: midpoint(ptrOr(maxPos), nil), URLs: []URL{}}
tx, err := s.db.Begin()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`INSERT INTO items (id, section_id, name, icon, position) VALUES (?, ?, ?, ?, ?)`,
it.ID, it.SectionID, it.Name, it.Icon, it.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if err := insertURLs(tx, it.ID, in.URLs, &it.URLs); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if err := tx.Commit(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, it)
}
// insertURLs writes the url list (position = index * 1024) and fills out.
func insertURLs(tx *sql.Tx, itemID string, in []URLInput, out *[]URL) error {
for i, u := range in {
row := URL{ID: newID("u"), URL: u.URL, Label: u.Label, Position: float64(i+1) * 1024}
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
row.ID, itemID, row.URL, row.Label, row.Position); err != nil {
return err
}
*out = append(*out, row)
}
return nil
}
func (s *Server) updateItem(c *gin.Context) {
var in struct {
Name *string `json:"name"`
Icon *string `json:"icon"`
URLs *[]URLInput `json:"urls"`
}
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
if in.URLs != nil {
for _, u := range *in.URLs {
if !validURL(u.URL) {
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
return
}
}
}
id := c.Param("id")
tx, err := s.db.Begin()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer func() { _ = tx.Rollback() }()
if in.Name != nil {
if _, err := tx.Exec(`UPDATE items SET name = ? WHERE id = ?`, *in.Name, id); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if in.Icon != nil {
if _, err := tx.Exec(`UPDATE items SET icon = ? WHERE id = ?`, *in.Icon, id); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if in.URLs != nil {
if _, err := tx.Exec(`DELETE FROM urls WHERE item_id = ?`, id); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
var out []URL
if err := insertURLs(tx, id, *in.URLs, &out); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if err := tx.Commit(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
it, err := s.loadItem(id)
if notFound(err) {
fail(c, http.StatusNotFound, "item not found")
return
}
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, it)
}
// loadItem reads one item with its urls.
func (s *Server) loadItem(id string) (Item, error) {
var it Item
var cfg sql.NullString
err := s.db.QueryRow(`SELECT id, section_id, kind, name, icon, position, config FROM items WHERE id = ?`, id).
Scan(&it.ID, &it.SectionID, &it.Kind, &it.Name, &it.Icon, &it.Position, &cfg)
if err != nil {
return it, err
}
if cfg.Valid {
raw := json.RawMessage(cfg.String)
it.Config = &raw
}
it.URLs = []URL{}
rows, err := s.db.Query(`SELECT id, url, label, position FROM urls WHERE item_id = ? ORDER BY position`, id)
if err != nil {
return it, err
}
defer rows.Close()
for rows.Next() {
var u URL
if err := rows.Scan(&u.ID, &u.URL, &u.Label, &u.Position); err != nil {
return it, err
}
it.URLs = append(it.URLs, u)
}
return it, rows.Err()
}
func (s *Server) deleteItem(c *gin.Context) {
res, err := s.db.Exec(`DELETE FROM items 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, "item not found")
return
}
c.Status(http.StatusNoContent)
}
func (s *Server) reorderItem(c *gin.Context) {
var in struct {
ID string `json:"id"`
SectionID *string `json:"sectionId"`
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("items", in.BeforeID, in.AfterID)
if err != nil {
fail(c, http.StatusBadRequest, err.Error())
return
}
if in.SectionID == nil {
if err := s.db.QueryRow(`SELECT section_id FROM items WHERE id = ?`, in.ID).Scan(&in.SectionID); notFound(err) {
fail(c, http.StatusNotFound, "item not found")
return
} else if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
res, err := s.db.Exec(`UPDATE items SET position = ?, section_id = ? WHERE id = ?`, pos, *in.SectionID, in.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "item not found")
return
}
it, err := s.loadItem(in.ID)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, it)
}
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// allSettings reads the whole settings table into a key/value map.
func (s *Server) allSettings() (map[string]json.RawMessage, error) {
rows, err := s.db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]json.RawMessage{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = json.RawMessage(v)
}
return out, rows.Err()
}
func (s *Server) getSettings(c *gin.Context) {
m, err := s.allSettings()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, m)
}
// putSettings merges the posted keys into the settings table.
func (s *Server) putSettings(c *gin.Context) {
var in map[string]json.RawMessage
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
tx, err := s.db.Begin()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer func() { _ = tx.Rollback() }()
for k, v := range in {
if _, err := tx.Exec(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if err := tx.Commit(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
m, err := s.allSettings()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, m)
}
+113
View File
@@ -0,0 +1,113 @@
package api
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// statusEntry is one cached reachability probe result.
type statusEntry struct {
Status string `json:"status"` // "up" | "down"
LatencyMs int64 `json:"latencyMs"`
CheckedAt time.Time `json:"checkedAt"`
}
// statusCache is a TTL map of url -> probe result. Pings are HEAD requests;
// any HTTP response < 500 means the host answered, so the service is up.
// ponytail: pings the primary URL only — extend to all urls if demanded.
type statusCache struct {
mu sync.Mutex
ttl time.Duration
entries map[string]statusEntry
client *http.Client
}
func newStatusCache(ttl time.Duration) *statusCache {
return &statusCache{
ttl: ttl,
entries: map[string]statusEntry{},
client: &http.Client{Timeout: 4 * time.Second},
}
}
// check returns the cached probe if fresh, otherwise pings synchronously.
func (sc *statusCache) check(url string) statusEntry {
sc.mu.Lock()
if e, ok := sc.entries[url]; ok && time.Since(e.CheckedAt) < sc.ttl {
sc.mu.Unlock()
return e
}
sc.mu.Unlock()
e := sc.ping(url)
sc.mu.Lock()
sc.entries[url] = e
sc.mu.Unlock()
return e
}
func (sc *statusCache) ping(url string) statusEntry {
start := time.Now()
e := statusEntry{Status: "down", CheckedAt: start}
req, err := http.NewRequest(http.MethodHead, url, nil)
if err != nil {
return e
}
resp, err := sc.client.Do(req)
e.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
return e
}
defer resp.Body.Close()
if resp.StatusCode < 500 {
e.Status = "up"
}
return e
}
// getStatus probes the primary URL of every service item, in parallel,
// and returns a map keyed by item id.
func (s *Server) getStatus(c *gin.Context) {
rows, err := s.db.Query(
`SELECT i.id, u.url FROM items i
JOIN urls u ON u.item_id = i.id
WHERE i.kind = 'service'
AND u.position = (SELECT MIN(position) FROM urls WHERE item_id = i.id)`)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
type target struct{ id, url string }
var targets []target
for rows.Next() {
var t target
if err := rows.Scan(&t.id, &t.url); err != nil {
rows.Close()
fail(c, http.StatusInternalServerError, err.Error())
return
}
targets = append(targets, t)
}
rows.Close()
out := make(map[string]statusEntry, len(targets))
var mu sync.Mutex
var wg sync.WaitGroup
for _, t := range targets {
wg.Add(1)
go func(t target) {
defer wg.Done()
e := s.status.check(t.url)
mu.Lock()
out[t.id] = e
mu.Unlock()
}(t)
}
wg.Wait()
c.JSON(http.StatusOK, out)
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
)
// Export is the portable backup shape: version + settings + board tree.
type Export struct {
Version int `json:"version"`
Settings map[string]json.RawMessage `json:"settings"`
Sections []Section `json:"sections"`
}
func (s *Server) exportBoard(c *gin.Context) {
settings, err := s.allSettings()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sections, err := s.board()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, Export{Version: 1, Settings: settings, Sections: sections})
}
// importBoard replaces board state inside one transaction. Ids from the
// payload are preserved so a round-trip restores identical state.
func (s *Server) importBoard(c *gin.Context) {
var in Export
if err := c.ShouldBindJSON(&in); err != nil || in.Sections == nil {
fail(c, http.StatusBadRequest, "invalid export payload")
return
}
tx, err := s.db.Begin()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer func() { _ = tx.Rollback() }()
for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} {
if _, err := tx.Exec(q); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
for _, sec := range in.Sections {
if sec.ID == "" || sec.Name == "" {
fail(c, http.StatusBadRequest, "section missing id or name")
return
}
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
for _, it := range sec.Items {
if it.ID == "" || it.Name == "" {
fail(c, http.StatusBadRequest, "item missing id or name")
return
}
var cfg any
if it.Config != nil {
cfg = string(*it.Config)
}
if _, err := tx.Exec(
`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
for _, u := range it.URLs {
if !validURL(u.URL) {
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
return
}
uid := u.ID
if uid == "" {
uid = newID("u")
}
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
uid, it.ID, u.URL, u.Label, u.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
}
}
for k, v := range in.Settings {
if _, err := tx.Exec(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
}
if err := tx.Commit(); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sections, err := s.board()
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, sections)
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
@@ -0,0 +1,36 @@
-- +goose Up
-- Board tree: sections > items > urls. position REAL = fractional ordering;
-- a drag writes one row instead of renumbering the set.
CREATE TABLE sections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
position REAL NOT NULL,
collapsed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE items (
id TEXT PRIMARY KEY,
section_id TEXT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'service' CHECK (kind IN ('service', 'widget')),
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '',
position REAL NOT NULL,
config JSON,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX items_section_pos ON items(section_id, position);
CREATE TABLE urls (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
url TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
position REAL NOT NULL
);
CREATE INDEX urls_item_pos ON urls(item_id, position);
-- +goose Down
DROP TABLE urls;
DROP TABLE items;
DROP TABLE sections;
+8
View File
@@ -4,6 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dash</title>
<script>
// Pre-paint theme: localStorage mirrors the server setting, applied
// before first paint so there is no flash of the wrong theme.
try {
var t = localStorage.getItem("dash.theme");
document.documentElement.dataset.theme = t === "light" ? "light" : "dark";
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
+11 -1
View File
@@ -11,13 +11,23 @@
},
"dependencies": {
"@dash/api-client": "*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.5",
"@fontsource-variable/geist-mono": "^5.2.5",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.13",
+42 -34
View File
@@ -1,20 +1,25 @@
import { useEffect, useState } from "react";
import { Moon, Plus, Sun } from "lucide-react";
type Theme = "dark" | "light";
function initialTheme(): Theme {
const saved = localStorage.getItem("dash.theme");
return saved === "light" ? "light" : "dark";
}
import { useState } from "react";
import { Moon, Plus, Settings, Sun } from "lucide-react";
import { BoardProvider, useBoard } from "@/board/store";
import { Board } from "@/board/Board";
import { ServiceDialog } from "@/components/ServiceDialog";
import { SettingsDialog } from "@/components/SettingsDialog";
import { Button } from "@/components/ui/button";
import type { Item } from "@/types";
export default function App() {
const [theme, setTheme] = useState<Theme>(initialTheme);
return (
<BoardProvider>
<Shell />
</BoardProvider>
);
}
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem("dash.theme", theme);
}, [theme]);
function Shell() {
const board = useBoard();
const [settingsOpen, setSettingsOpen] = useState(false);
const [svcOpen, setSvcOpen] = useState(false);
const [editing, setEditing] = useState<Item | undefined>();
return (
<div className="font-sans">
@@ -28,27 +33,30 @@ export default function App() {
</svg>
Dash
</div>
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
className="ml-auto flex size-[34px] items-center justify-center rounded-lg border border-border bg-surface text-text-dim transition-colors hover:border-border-strong hover:text-text active:scale-95"
>
{theme === "dark" ? <Moon size={15} strokeWidth={1.8} /> : <Sun size={15} strokeWidth={1.8} />}
</button>
<button
type="button"
className="flex h-[34px] items-center gap-1.5 rounded-lg bg-text px-3.5 text-[13px] font-medium text-bg transition-transform active:scale-95"
>
<Plus size={13} strokeWidth={2.2} />
Add service
</button>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline" size="icon" aria-label="Settings"
onClick={() => setSettingsOpen(true)}
>
<Settings size={15} strokeWidth={1.8} />
</Button>
<Button
variant="outline" size="icon" aria-label="Toggle theme"
onClick={() => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark")}
>
{board.theme === "dark" ? <Moon size={15} strokeWidth={1.8} /> : <Sun size={15} strokeWidth={1.8} />}
</Button>
<Button onClick={() => { setEditing(undefined); setSvcOpen(true); }}>
<Plus size={13} strokeWidth={2.2} />
Add service
</Button>
</div>
</header>
<main className="mx-auto max-w-[1140px] px-7 pb-24 pt-9">
<p className="text-center text-[13px] text-text-faint">
No sections yet. Add a service to get started.
</p>
</main>
<Board onEditItem={(it) => { setEditing(it); setSvcOpen(true); }} />
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing} />
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createClient } from "@dash/api-client";
/** Same-origin typed client; vite proxies /api in dev. */
export const api = createClient();
+119
View File
@@ -0,0 +1,119 @@
import { useState, type FormEvent } from "react";
import { Plus } from "lucide-react";
import { useBoard } from "./store";
import { BoardDnd, ItemSortZone, SectionSortList } from "./dnd";
import { renderers } from "@/renderers";
import type { Item } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
// Board is the renderer-agnostic glue: state + dnd + sort contexts wired once,
// the active renderer supplies the chrome.
export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) {
const board = useBoard();
const views = renderers[board.renderer];
const [addingSection, setAddingSection] = useState(false);
const [sectionName, setSectionName] = useState("");
async function submitSection(e: FormEvent) {
e.preventDefault();
const name = sectionName.trim();
if (!name) return;
await board.addSection(name);
setSectionName("");
setAddingSection(false);
}
if (!board.loaded) {
return <main className="mx-auto max-w-[1140px] px-7 pb-24 pt-9" />;
}
if (board.sections.length === 0) {
return (
<main className="mx-auto flex max-w-[1140px] flex-col items-center gap-4 px-7 pb-24 pt-24">
<p className="text-[13px] text-text-faint">No sections yet.</p>
<AddSectionRow
open={addingSection} setOpen={setAddingSection}
name={sectionName} setName={setSectionName} onSubmit={submitSection}
/>
</main>
);
}
return (
<main className="mx-auto max-w-[1140px] px-7 pb-24 pt-9">
<BoardDnd>
<SectionSortList ids={board.sections.map((s) => s.id)}>
<views.BoardView>
{board.sections.map((sec) => (
<views.SectionView
key={sec.id}
section={sec}
onToggle={() => void board.patchSection(sec.id, { collapsed: !sec.collapsed })}
onDelete={() => {
if (window.confirm(`Delete section "${sec.name}" and its ${sec.items.length} items?`)) {
void board.deleteSection(sec.id);
}
}}
>
<ItemSortZone section={sec} className={views.itemsGridClass}>
{sec.items.map((it) =>
it.kind === "widget" ? (
<views.WidgetView key={it.id} item={it} sectionId={sec.id} onEdit={() => onEditItem(it)} />
) : (
<views.ItemView
key={it.id}
item={it}
sectionId={sec.id}
status={board.statuses[it.id]}
onEdit={() => onEditItem(it)}
/>
),
)}
</ItemSortZone>
</views.SectionView>
))}
</views.BoardView>
</SectionSortList>
</BoardDnd>
<div className="mt-8 flex justify-center">
<AddSectionRow
open={addingSection} setOpen={setAddingSection}
name={sectionName} setName={setSectionName} onSubmit={submitSection}
/>
</div>
</main>
);
}
// Inline add-section row — one field doesn't warrant a dialog.
function AddSectionRow({
open, setOpen, name, setName, onSubmit,
}: {
open: boolean; setOpen: (o: boolean) => void;
name: string; setName: (n: string) => void;
onSubmit: (e: FormEvent) => void;
}) {
if (!open) {
return (
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<Plus size={12} /> Add section
</Button>
);
}
return (
<form onSubmit={onSubmit} className="flex gap-2">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Section name"
className="w-[200px]"
onBlur={() => !name.trim() && setOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
/>
<Button type="submit" size="sm">Add</Button>
</form>
);
}
+251
View File
@@ -0,0 +1,251 @@
import {
useState,
type CSSProperties,
type ReactNode,
} from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
rectIntersection,
useDroppable,
useSensor,
useSensors,
type CollisionDetection,
type DraggableAttributes,
type DraggableSyntheticListeners,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Section } from "@/types";
import { useBoard } from "./store";
// Shared drag-drop primitives. Renderers consume the hooks; they never
// wire sensors, contexts, or persistence themselves.
interface DragData {
type: "section" | "item" | "sectionBody";
sectionId?: string;
}
/** dnd-kit types data.current as any; our hooks below only ever attach DragData. */
function dragData(d: { data: { current: unknown } }): DragData | undefined {
// SAFETY: payloads are set exclusively by useItemDnd/useSectionDnd/ItemSortZone.
return d.data.current as DragData | undefined;
}
const collision: CollisionDetection = (args) => {
const p = pointerWithin(args);
return p.length > 0 ? p : rectIntersection(args);
};
// Pointerup fires a click on whatever sits under the cursor, which would open
// a tile's URL popover right after a drop. Stamp the last real drag and let
// ItemAnchor swallow clicks that arrive immediately after it.
let lastDropAt = 0;
export function clickIsDragTail(): boolean {
return performance.now() - lastDropAt < 200;
}
/** Wraps the whole board: sensors, drag lifecycle, overlay, persistence. */
export function BoardDnd({ children }: { children: ReactNode }) {
const board = useBoard();
const [activeLabel, setActiveLabel] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
function onDragStart(e: DragStartEvent) {
const data = dragData(e.active);
if (data?.type === "item") {
const item = board.sections
.flatMap((s) => s.items)
.find((i) => i.id === e.active.id);
setActiveLabel(item?.name ?? null);
} else {
const sec = board.sections.find((s) => s.id === e.active.id);
setActiveLabel(sec?.name ?? null);
}
}
function onDragOver(e: DragOverEvent) {
const active = dragData(e.active);
const over = e.over ? dragData(e.over) : undefined;
if (!active || active.type !== "item" || !over || !e.over) return;
const activeId = String(e.active.id);
if (over.type === "item" && over.sectionId && over.sectionId !== active.sectionId) {
const items = board.sections.find((s) => s.id === over.sectionId)?.items ?? [];
const idx = items.findIndex((i) => i.id === String(e.over?.id));
board.moveItemLocal(activeId, over.sectionId, idx < 0 ? items.length : idx);
} else if (over.type === "sectionBody" && over.sectionId && over.sectionId !== active.sectionId) {
const len = board.sections.find((s) => s.id === over.sectionId)?.items.length ?? 0;
board.moveItemLocal(activeId, over.sectionId, len);
} else if (over.type === "section" && over.sectionId !== active.sectionId) {
const len = board.sections.find((s) => s.id === String(e.over?.id))?.items.length ?? 0;
board.moveItemLocal(activeId, String(e.over?.id), len);
}
}
function onDragEnd(e: DragEndEvent) {
setActiveLabel(null);
lastDropAt = performance.now();
const active = dragData(e.active);
const overId = e.over ? String(e.over.id) : null;
if (!active || !overId) return;
if (active.type === "section") {
const over = e.over ? dragData(e.over) : undefined;
// Dropping anywhere inside another section — its body or an item —
// counts as reordering onto that section.
const targetId =
over?.type === "section" ? overId :
over?.sectionId ?? null;
if (!targetId) return;
const ids = board.sections.map((s) => s.id);
const from = ids.indexOf(String(e.active.id));
const to = ids.indexOf(targetId);
if (from < 0 || to < 0 || from === to) return;
const next = arrayMove(board.sections, from, to);
board.moveSectionLocal(String(e.active.id), to);
const idx = next.findIndex((s) => s.id === e.active.id);
void board.persistSectionMove(
String(e.active.id),
idx > 0 ? next[idx - 1]?.id : undefined,
idx < next.length - 1 ? next[idx + 1]?.id : undefined,
);
return;
}
if (active.type === "item") {
const id = String(e.active.id);
const over = e.over ? dragData(e.over) : undefined;
const sec = board.sections.find((s) => s.items.some((i) => i.id === id));
if (!sec) return;
// onDragOver only mutates local state for cross-section moves; for a
// same-section drop the order is still pre-drag, so apply it now.
let items = sec.items;
const from = items.findIndex((i) => i.id === id);
if (over?.type === "item" && over.sectionId === sec.id) {
const to = items.findIndex((i) => i.id === overId);
if (to >= 0 && to !== from) {
items = arrayMove(items, from, to);
board.moveItemLocal(id, sec.id, to);
}
} else if (over?.type === "sectionBody" && over.sectionId === sec.id) {
items = arrayMove(items, from, items.length - 1);
board.moveItemLocal(id, sec.id, items.length - 1);
}
const idx = items.findIndex((i) => i.id === id);
void board.persistItemMove(
id,
sec.id,
idx > 0 ? items[idx - 1]?.id : undefined,
idx < items.length - 1 ? items[idx + 1]?.id : undefined,
);
}
}
function onDragCancel() {
setActiveLabel(null);
void board.refresh();
}
return (
<DndContext
sensors={sensors}
collisionDetection={collision}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
>
{children}
<DragOverlay dropAnimation={null}>
{activeLabel ? (
<div className="rounded-[10px] border border-border-strong bg-popover px-4 py-2.5 text-[13px] font-medium shadow-xl">
{activeLabel}
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
/** Sortable context for the section list (vertical). Shared, not per-renderer. */
export function SectionSortList({ ids, children }: { ids: string[]; children: ReactNode }) {
return (
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{children}
</SortableContext>
);
}
/** Sortable context + droppable body for one section's items.
The div carries the renderer's grid classes so the drop zone has a real box. */
export function ItemSortZone({ section, className, children }: { section: Section; className?: string; children: ReactNode }) {
const { setNodeRef } = useDroppable({
id: `body:${section.id}`,
data: { type: "sectionBody", sectionId: section.id } satisfies DragData,
});
return (
<SortableContext items={section.items.map((i) => i.id)} strategy={rectSortingStrategy}>
<div ref={setNodeRef} className={className} style={{ minHeight: 8 }}>
{children}
</div>
</SortableContext>
);
}
export interface SortableBind {
ref: (el: HTMLElement | null) => void;
style: CSSProperties;
isDragging: boolean;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
}
/** Item-level sortable binding. ItemView applies ref/style and spreads
attributes/listeners on the tile (whole-tile drag; clicks pass through
because the pointer sensor requires 6px of movement). */
export function useItemDnd(sectionId: string, itemId: string): SortableBind {
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
id: itemId,
data: { type: "item", sectionId } satisfies DragData,
});
return {
ref: setNodeRef,
style: { transform: CSS.Transform.toString(transform), transition },
isDragging,
attributes,
listeners,
};
}
/** Section-level sortable binding. Handle props go on the grip button. */
export function useSectionDnd(sectionId: string): SortableBind {
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
id: sectionId,
data: { type: "section", sectionId } satisfies DragData,
});
return {
ref: setNodeRef,
style: { transform: CSS.Transform.toString(transform), transition },
isDragging,
attributes,
listeners,
};
}
+116
View File
@@ -0,0 +1,116 @@
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { Item, Status } from "@/types";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { clickIsDragTail } from "@/board/dnd";
/** host extracts the display host from a service URL. */
export function hostOf(url: string): string {
try {
const u = new URL(url);
return u.host + (u.pathname !== "/" ? u.pathname.replace(/\/$/, "") : "");
} catch {
return url;
}
}
/** IconImg renders the item icon or a letter tile fallback. */
export function IconImg({ item, size = 40, className }: { item: Item; size?: number; className?: string }) {
const [failed, setFailed] = useState(false);
const letter = item.name.trim().charAt(0).toUpperCase() || "?";
return (
<div
className={cn("relative flex shrink-0 items-center justify-center overflow-hidden bg-icon-bg text-text-dim font-semibold", className)}
style={{ width: size, height: size, borderRadius: Math.round(size * 0.225), fontSize: size * 0.375 }}
>
{!item.icon || failed ? (
letter
) : (
<img
src={item.icon}
alt=""
className="absolute inset-0 h-full w-full bg-icon-bg object-contain p-[15%]"
onError={() => setFailed(true)}
loading="lazy"
/>
)}
</div>
);
}
/** StatusChip — the only color on the board. Green up, amber down, faint when
unknown (no URLs or status not yet probed). */
export function StatusChip({ status, variant = "chip" }: { status?: Status | undefined; variant?: "chip" | "dot" }) {
const state = status?.status;
if (variant === "dot") {
return (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
state === "up" ? "bg-up" : state === "down" ? "bg-down" : "bg-text-faint/40",
)}
title={state ?? "unknown"}
/>
);
}
return (
<span
className={cn(
"ml-auto flex shrink-0 items-center gap-1.5 rounded-[5px] border border-border px-[7px] py-[3px]",
"font-mono text-[9.5px] tracking-[0.06em] text-text-faint",
)}
>
<span className={cn("size-[5px] rounded-full", state === "up" ? "bg-up" : state === "down" ? "bg-down" : "bg-text-faint/40")} />
<span className="max-[620px]:hidden">{state ?? "…"}</span>
</span>
);
}
/** ItemAnchor gives a tile its launch behavior: one URL opens directly,
multiple URLs open a label-tagged chooser popover, zero URLs open edit. */
export function ItemAnchor({
item,
onEdit,
children,
}: {
item: Item;
onEdit: () => void;
children: (open: () => void) => ReactNode;
}) {
const [open, setOpen] = useState(false);
const urls = item.urls;
const activate = () => {
if (clickIsDragTail()) return;
if (urls.length === 0) onEdit();
else if (urls.length === 1 && urls[0]) window.open(urls[0].url, "_blank", "noopener");
else setOpen(true);
};
if (urls.length < 2) return <>{children(activate)}</>;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>{children(activate)}</PopoverAnchor>
<PopoverContent onOpenAutoFocus={(e) => e.preventDefault()}>
{urls.map((u) => (
<a
key={u.id}
href={u.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] text-text transition-colors hover:bg-surface-hover"
>
{u.label ? (
<span className="shrink-0 rounded bg-icon-bg px-1.5 py-0.5 text-[9.5px] font-semibold uppercase tracking-[0.08em] text-text-dim">
{u.label}
</span>
) : null}
<span className="ml-auto truncate font-mono text-[11.5px] text-text-faint">{hostOf(u.url)}</span>
</a>
))}
</PopoverContent>
</Popover>
);
}
+226
View File
@@ -0,0 +1,226 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api } from "@/api";
import type {
Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
} from "@/types";
import { parsePrefs } from "@/types";
// Board store — single renderer-agnostic source of truth for the board tree,
// reachability statuses, and persisted settings. Renderers consume via useBoard.
interface BoardState {
sections: Section[];
statuses: StatusMap;
theme: Theme;
renderer: RendererName;
loaded: boolean;
refresh(): Promise<void>;
addSection(name: string): Promise<Section | null>;
patchSection(id: string, patch: { name?: string; collapsed?: boolean }): Promise<void>;
deleteSection(id: string): Promise<void>;
addItem(input: ItemInput): Promise<Item | null>;
patchItem(id: string, patch: ItemPatch): Promise<void>;
deleteItem(id: string): Promise<void>;
uploadIcon(id: string, file: File): Promise<void>;
moveItemLocal(itemId: string, toSectionId: string, index: number): void;
moveSectionLocal(id: string, index: number): void;
persistItemMove(itemId: string, sectionId: string, beforeId?: string, afterId?: string): Promise<void>;
persistSectionMove(id: string, beforeId?: string, afterId?: string): Promise<void>;
setSetting(key: keyof Prefs, value: string): Promise<void>;
}
const Ctx = createContext<BoardState | null>(null);
export function BoardProvider({ children }: { children: ReactNode }) {
const [sections, setSections] = useState<Section[]>([]);
const [statuses, setStatuses] = useState<StatusMap>({});
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento" });
const [loaded, setLoaded] = useState(false);
const refresh = useCallback(async () => {
const { data } = await api.GET("/api/sections");
if (data) setSections(data);
}, []);
const refreshStatus = useCallback(async () => {
const { data } = await api.GET("/api/status");
if (data) setStatuses(data);
}, []);
useEffect(() => {
void (async () => {
const [{ data: secs }, { data: cfg }] = await Promise.all([
api.GET("/api/sections"),
api.GET("/api/settings"),
]);
if (secs) setSections(secs);
if (cfg) setPrefs(parsePrefs(cfg));
setLoaded(true);
void refreshStatus();
})();
const t = setInterval(() => void refreshStatus(), 60_000);
return () => clearInterval(t);
}, [refreshStatus]);
const { theme, renderer } = prefs;
// Apply theme: <html data-theme> drives all CSS vars. localStorage mirrors
// the server setting so index.html can pre-paint without a flash.
useEffect(() => {
if (!loaded) return;
document.documentElement.dataset.theme = theme;
localStorage.setItem("dash.theme", theme);
}, [theme, loaded]);
const addSection = useCallback(async (name: string) => {
const { data } = await api.POST("/api/sections", { body: { name } });
if (data) setSections((s) => [...s, data]);
return data ?? null;
}, []);
const patchSection = useCallback(async (id: string, patch: { name?: string; collapsed?: boolean }) => {
setSections((ss) => ss.map((s) => (s.id === id ? { ...s, ...patch } : s)));
const { error } = await api.PATCH("/api/sections/{id}", { params: { path: { id } }, body: patch });
if (error) await refresh();
}, [refresh]);
const deleteSection = useCallback(async (id: string) => {
setSections((ss) => ss.filter((s) => s.id !== id));
const { error } = await api.DELETE("/api/sections/{id}", { params: { path: { id } } });
if (error) await refresh();
}, [refresh]);
const addItem = useCallback(async (input: ItemInput) => {
const { data } = await api.POST("/api/items", { body: input });
if (data) {
setSections((ss) => ss.map((s) => (s.id === data.sectionId ? { ...s, items: [...s.items, data] } : s)));
void refreshStatus();
}
return data ?? null;
}, [refreshStatus]);
const patchItem = useCallback(async (id: string, patch: ItemPatch) => {
const { data } = await api.PATCH("/api/items/{id}", { params: { path: { id } }, body: patch });
if (data) {
setSections((ss) => ss.map((s) => ({
...s,
items: s.items.map((i) => (i.id === id ? data : i)),
})));
void refreshStatus();
}
}, [refreshStatus]);
const deleteItem = useCallback(async (id: string) => {
setSections((ss) => ss.map((s) => ({ ...s, items: s.items.filter((i) => i.id !== id) })));
const { error } = await api.DELETE("/api/items/{id}", { params: { path: { id } } });
if (error) await refresh();
}, [refresh]);
const uploadIcon = useCallback(async (id: string, file: File) => {
const form = new FormData();
form.append("file", file);
const res = await fetch(`/api/items/${id}/icon`, { method: "POST", body: form });
if (res.ok) {
// SAFETY: /api/items/:id/icon returns Item per openapi.yaml; fetch isn't typed by the client.
const it = (await res.json()) as Item;
setSections((ss) => ss.map((s) => ({
...s,
items: s.items.map((i) => (i.id === id ? it : i)),
})));
}
}, []);
// Local moves run during drag (optimistic); persist* fire the API on drop.
const moveItemLocal = useCallback((itemId: string, toSectionId: string, index: number) => {
setSections((ss) => {
const next = ss.map((s) => ({ ...s, items: [...s.items] }));
let moved: Item | undefined;
for (const s of next) {
const i = s.items.findIndex((it) => it.id === itemId);
if (i >= 0) {
[moved] = s.items.splice(i, 1);
break;
}
}
if (!moved) return ss;
moved = { ...moved, sectionId: toSectionId };
const target = next.find((s) => s.id === toSectionId);
if (!target) return ss;
target.items.splice(Math.min(index, target.items.length), 0, moved);
return next;
});
}, []);
const moveSectionLocal = useCallback((id: string, index: number) => {
setSections((ss) => {
const next = [...ss];
const from = next.findIndex((s) => s.id === id);
if (from < 0) return ss;
const [moved] = next.splice(from, 1);
if (!moved) return ss;
next.splice(Math.min(index, next.length), 0, moved);
return next;
});
}, []);
const persistItemMove = useCallback(async (itemId: string, sectionId: string, beforeId?: string, afterId?: string) => {
const { error } = await api.POST("/api/items/reorder", {
body: {
id: itemId, sectionId,
...(beforeId ? { beforeId } : {}),
...(afterId ? { afterId } : {}),
},
});
if (error) await refresh();
}, [refresh]);
const persistSectionMove = useCallback(async (id: string, beforeId?: string, afterId?: string) => {
const { error } = await api.POST("/api/sections/reorder", {
body: {
id,
...(beforeId ? { beforeId } : {}),
...(afterId ? { afterId } : {}),
},
});
if (error) await refresh();
}, [refresh]);
const setSetting = useCallback(async (key: keyof Prefs, value: string) => {
setPrefs((p) => ({ ...p, [key]: value }));
const { data, error } = await api.PUT("/api/settings", {
body: { [key]: value },
});
if (error) await refresh();
else if (data) setPrefs(parsePrefs(data));
}, [refresh]);
const value = useMemo<BoardState>(() => ({
sections, statuses, theme, renderer, loaded,
refresh, addSection, patchSection, deleteSection,
addItem, patchItem, deleteItem, uploadIcon,
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
setSetting,
}), [
sections, statuses, theme, renderer, loaded,
refresh, addSection, patchSection, deleteSection,
addItem, patchItem, deleteItem, uploadIcon,
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
setSetting,
]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useBoard(): BoardState {
const v = useContext(Ctx);
if (!v) throw new Error("useBoard outside BoardProvider");
return v;
}
@@ -0,0 +1,198 @@
import { useEffect, useRef, useState } from "react";
import { Plus, Trash2, Upload, X } from "lucide-react";
import { useBoard } from "@/board/store";
import type { Item, UrlInput } from "@/types";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// One dialog for both add and edit: item present = edit, absent = add.
export function ServiceDialog({
open,
onOpenChange,
item,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
item?: Item | undefined;
}) {
const board = useBoard();
const editing = Boolean(item);
const [name, setName] = useState("");
const [icon, setIcon] = useState("");
const [urls, setUrls] = useState<UrlInput[]>([{ url: "", label: "local" }]);
const [sectionId, setSectionId] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setErr("");
setName(item?.name ?? "");
setIcon(item?.icon ?? "");
setUrls(
item && item.urls.length > 0
? item.urls.map((u) => ({ url: u.url, label: u.label }))
: [{ url: "", label: "local" }],
);
setSectionId(item?.sectionId ?? board.sections[0]?.id ?? "");
}, [open, item, board.sections]);
const setUrl = (i: number, patch: Partial<UrlInput>) =>
setUrls((us) => us.map((u, j) => (j === i ? { ...u, ...patch } : u)));
async function submit() {
const cleanUrls = urls.filter((u) => u.url.trim() !== "");
if (!name.trim()) return setErr("Name is required"), undefined;
if (cleanUrls.some((u) => !/^https?:\/\/.+/.test(u.url))) {
return setErr("URLs must start with http:// or https://"), undefined;
}
setBusy(true);
try {
const file = fileRef.current?.files?.[0];
if (editing && item) {
await board.patchItem(item.id, { name: name.trim(), icon, urls: cleanUrls });
if (file) await board.uploadIcon(item.id, file);
} else {
let sid = sectionId;
if (!sid) {
// First service on an empty board — create the home section.
const sec = await board.addSection("Services");
if (!sec) throw new Error("could not create section");
sid = sec.id;
}
const created = await board.addItem({
sectionId: sid,
name: name.trim(),
icon,
urls: cleanUrls,
});
if (created && file) await board.uploadIcon(created.id, file);
}
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
} finally {
setBusy(false);
}
}
async function remove() {
if (!item) return;
setBusy(true);
await board.deleteItem(item.id);
setBusy(false);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit service" : "Add service"}</DialogTitle>
<DialogDescription>
{editing ? "Update name, icon, or launch URLs." : "Name it, give it an icon, add one or more URLs."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_150px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-name">Name</Label>
<Input id="svc-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
</div>
{!editing && board.sections.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label>Section</Label>
<Select value={sectionId} onValueChange={setSectionId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{board.sections.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label>URLs</Label>
{urls.map((u, i) => (
<div key={i} className="flex gap-2">
<Input
value={u.url}
onChange={(e) => setUrl(i, { url: e.target.value })}
placeholder="https://…"
className="font-mono text-[12px]"
/>
<Input
value={u.label ?? ""}
onChange={(e) => setUrl(i, { label: e.target.value })}
placeholder="label"
className="w-[110px] shrink-0"
list="url-labels"
/>
<Button
type="button" variant="ghost" size="icon"
aria-label="Remove URL"
disabled={urls.length <= 1}
onClick={() => setUrls((us) => us.filter((_, j) => j !== i))}
>
<X size={13} />
</Button>
</div>
))}
<datalist id="url-labels">
<option value="local" /><option value="external" />
</datalist>
<Button
type="button" variant="outline" size="sm"
className="mt-1 w-fit"
onClick={() => setUrls((us) => [...us, { url: "", label: "" }])}
>
<Plus size={12} /> Add URL
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-icon">Icon URL</Label>
<div className="flex gap-2">
<Input id="svc-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
<Button type="button" variant="outline" size="icon" aria-label="Upload icon file" onClick={() => fileRef.current?.click()}>
<Upload size={13} />
</Button>
<input
ref={fileRef} type="file" accept="image/*" className="hidden"
onChange={() => setErr("")}
/>
</div>
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
</div>
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter className="items-center">
{editing ? (
<Button type="button" variant="destructive" size="sm" className="mr-auto" onClick={() => void remove()} disabled={busy}>
<Trash2 size={12} /> Delete
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add service"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,95 @@
import { useRef, useState } from "react";
import { Download, Upload } from "lucide-react";
import { useBoard } from "@/board/store";
import { rendererLabels, rendererList } from "@/renderers";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const board = useBoard();
const fileRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState("");
async function exportJson() {
const res = await fetch("/api/export");
if (!res.ok) return;
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "dash-export.json";
a.click();
URL.revokeObjectURL(a.href);
}
async function importJson(file: File) {
setMsg("");
const res = await fetch("/api/import", { method: "POST", body: file });
if (res.ok) {
location.reload(); // import replaces state; a reload re-syncs everything
} else {
setMsg("Import failed — not a Dash export?");
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Board layout, theme, and backup.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Renderer</Label>
<Select value={board.renderer} onValueChange={(v) => {
if (v === "bento" || v === "cards") void board.setSetting("renderer", v);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{rendererList.map((r) => (
<SelectItem key={r} value={r}>{rendererLabels[r]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>Theme</Label>
<Select value={board.theme} onValueChange={(v) => {
if (v === "dark" || v === "light") void board.setSetting("theme", v);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2 pt-1">
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
<Download size={12} /> Export JSON
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={12} /> Import
</Button>
<input
ref={fileRef} type="file" accept="application/json" className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void importJson(f);
e.target.value = "";
}}
/>
</div>
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,38 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0 active:scale-[0.97]",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-border bg-surface text-text-dim hover:border-border-strong hover:text-text",
ghost: "text-text-dim hover:bg-surface-hover hover:text-text",
destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20",
},
size: {
default: "h-[34px] px-3.5",
sm: "h-8 px-3 text-xs",
icon: "size-[34px]",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
@@ -0,0 +1,60 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-black/60 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-1/2 z-50 w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2",
"rounded-xl border border-border-strong bg-popover p-5 text-text shadow-2xl",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<X size={14} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("mb-4 flex flex-col gap-1", className)} {...props} />;
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-[15px] font-semibold tracking-tight", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn("text-[13px] text-text-dim", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("mt-5 flex justify-end gap-2", className)} {...props} />;
}
export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-border bg-surface px-3 text-[13px] text-text",
"placeholder:text-text-faint focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Input };
+15
View File
@@ -0,0 +1,15 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
className={cn("text-[12px] font-medium text-text-dim select-none", className)}
{...props}
/>
);
}
export { Label };
@@ -0,0 +1,33 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverTrigger = PopoverPrimitive.Trigger;
function PopoverContent({
className,
align = "start",
sideOffset = 6,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
className={cn(
"z-30 min-w-[235px] rounded-[10px] border border-border-strong bg-popover p-[5px] text-text",
"shadow-[0_8px_30px_rgba(0,0,0,0.35),0_1px_3px_rgba(0,0,0,0.2)]",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-[0.98]",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverAnchor, PopoverTrigger, PopoverContent };
@@ -0,0 +1,69 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectValue = SelectPrimitive.Value;
function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-border bg-surface px-3",
"text-[13px] text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
"[&>span]:truncate",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown size={13} className="text-text-faint" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={6}
className={cn(
"z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-[10px]",
"border border-border-strong bg-popover p-[5px] text-text shadow-xl",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-[0.98]",
className,
)}
{...props}
>
<SelectPrimitive.Viewport>{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-8 pr-3 text-[13px]",
"outline-none data-[highlighted]:bg-surface-hover",
className,
)}
{...props}
>
<span className="absolute left-2 flex items-center">
<SelectPrimitive.ItemIndicator>
<Check size={13} />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem };
+57 -1
View File
@@ -1,9 +1,12 @@
@import "tailwindcss";
@import "tw-animate-css";
/* Design tokens — ported from mockups/d-bento-mono.html.
Semantic vars flip on [data-theme]; Tailwind utilities map via @theme. */
Semantic vars flip on [data-theme]; Tailwind utilities map via @theme.
shadcn/ui names (background, popover, ...) alias the same tokens. */
:root {
--ease: cubic-bezier(0.2, 0, 0, 1);
--radius: 0.5rem;
}
[data-theme="dark"] {
@@ -18,8 +21,24 @@
--accent: #3b82f6;
--up: #22c55e;
--down: #f59e0b;
--destructive: #ef4444;
--icon-bg: #1c1c1e;
--popover: #141414;
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--popover-foreground: var(--text);
--primary: var(--text);
--primary-foreground: var(--bg);
--secondary: var(--surface);
--secondary-foreground: var(--text);
--muted: var(--surface-hover);
--muted-foreground: var(--text-dim);
--accent-foreground: var(--text);
--input: var(--border-strong);
--ring: var(--accent);
}
[data-theme="light"] {
@@ -34,8 +53,24 @@
--accent: #2563eb;
--up: #16a34a;
--down: #d97706;
--destructive: #dc2626;
--icon-bg: #f4f4f5;
--popover: #ffffff;
--background: var(--bg);
--foreground: var(--text);
--card: var(--surface);
--card-foreground: var(--text);
--popover-foreground: var(--text);
--primary: var(--text);
--primary-foreground: var(--bg);
--secondary: var(--surface);
--secondary-foreground: var(--text);
--muted: var(--surface-hover);
--muted-foreground: var(--text-dim);
--accent-foreground: var(--text);
--input: var(--border-strong);
--ring: var(--accent);
}
@theme inline {
@@ -50,8 +85,29 @@
--color-accent: var(--accent);
--color-up: var(--up);
--color-down: var(--down);
--color-destructive: var(--destructive);
--color-icon-bg: var(--icon-bg);
--color-popover: var(--popover);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent-foreground: var(--accent-foreground);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--font-sans: "Geist Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono Variable", ui-monospace, monospace;
}
+139
View File
@@ -0,0 +1,139 @@
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
import type {
BoardViewProps,
ItemViewProps,
RendererViews,
SectionViewProps,
} from "@/renderers/types";
// Bento — default renderer. Mixed-size grid, hairline section headers,
// status chips carry the only color. Ported from mockups/d-bento-mono.html.
function BoardView({ children }: BoardViewProps) {
return <div className="flex flex-col gap-5">{children}</div>;
}
function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) {
const dnd = useSectionDnd(section.id);
return (
<section
ref={dnd.ref}
style={dnd.style}
className={cn(dnd.isDragging && "opacity-35")}
>
<div className="group/sec flex cursor-pointer select-none items-center gap-3 px-0.5 py-1">
<button
type="button"
aria-label="Drag section"
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
>
<GripVertical size={13} />
</button>
<button
type="button"
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-dim">
{section.name}
</h2>
<span className="font-mono text-[11px] text-text-faint">
{String(section.items.length).padStart(2, "0")}
</span>
<span className="h-px flex-1 bg-border" />
<ChevronDown
size={13}
className={cn("text-text-faint transition-transform duration-200", section.collapsed && "-rotate-90")}
/>
</button>
<button
type="button"
aria-label="Delete section"
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
<Trash2 size={12} />
</button>
</div>
{!section.collapsed && children}
</section>
);
}
function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) {
const dnd = useItemDnd(sectionId, item.id);
const host = item.urls[0] ? hostOf(item.urls[0].url) : "";
return (
<ItemAnchor item={item} onEdit={onEdit}>
{(open) => (
<div
ref={dnd.ref}
style={dnd.style}
{...dnd.attributes}
{...dnd.listeners}
onClick={open}
className={cn(
"group/tile relative col-span-1 flex min-h-[70px] cursor-grab items-center gap-3",
"rounded-[10px] border border-border bg-surface px-[15px] py-[13px]",
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
"min-[620px]:col-span-2",
dnd.isDragging && "opacity-35",
)}
>
<IconImg item={item} size={40} />
<div className="min-w-0">
<div className="truncate text-[13.5px] font-medium tracking-tight">{item.name}</div>
{host ? <div className="truncate font-mono text-[10.5px] text-text-faint">{host}</div> : null}
</div>
{item.urls.length > 0 && <StatusChip status={status} />}
<button
type="button"
aria-label={`Edit ${item.name}`}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="absolute right-2 top-2 rounded p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/tile:opacity-100"
>
<Pencil size={11} />
</button>
</div>
)}
</ItemAnchor>
);
}
// Widget tiles land in Phase 2; render a quiet placeholder tile meanwhile.
function WidgetView({ item, sectionId }: ItemViewProps) {
const dnd = useItemDnd(sectionId, item.id);
return (
<div
ref={dnd.ref}
style={dnd.style}
{...dnd.attributes}
{...dnd.listeners}
className={cn(
"col-span-1 flex min-h-[70px] items-center rounded-[10px] border border-dashed border-border-strong px-[15px] py-[13px]",
"min-[620px]:col-span-2",
dnd.isDragging && "opacity-35",
)}
>
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-text-faint">
{item.name}
</span>
</div>
);
}
export const bento: RendererViews = {
BoardView,
SectionView,
ItemView,
WidgetView,
itemsGridClass: "grid grid-cols-2 min-[620px]:grid-cols-4 min-[900px]:grid-cols-6 gap-2.5",
};
+132
View File
@@ -0,0 +1,132 @@
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
import type {
BoardViewProps,
ItemViewProps,
RendererViews,
SectionViewProps,
} from "@/renderers/types";
// Cards — uniform compact grid. Second renderer; proves the seam while the
// code is small. Ported from mockups/a-vercel-mono.html.
function BoardView({ children }: BoardViewProps) {
return <div className="flex flex-col gap-10">{children}</div>;
}
function SectionView({ section, onToggle, onDelete, children }: SectionViewProps) {
const dnd = useSectionDnd(section.id);
return (
<section ref={dnd.ref} style={dnd.style} className={cn(dnd.isDragging && "opacity-40")}>
<div className="group/sec mb-3.5 flex cursor-pointer select-none items-center gap-2.5 px-1 py-1.5 text-text-dim">
<button
type="button"
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
>
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em]">{section.name}</h2>
<span className="text-[11px] tabular-nums text-text-faint">{section.items.length}</span>
</button>
<button
type="button"
aria-label="Drag section"
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
>
<GripVertical size={13} />
</button>
<button
type="button"
aria-label="Delete section"
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
<Trash2 size={12} />
</button>
<ChevronDown
size={13}
className={cn("text-text-faint transition-transform duration-200", section.collapsed && "-rotate-90")}
/>
</div>
{!section.collapsed && children}
</section>
);
}
function ItemView({ item, sectionId, status, onEdit }: ItemViewProps) {
const dnd = useItemDnd(sectionId, item.id);
const host = item.urls[0] ? hostOf(item.urls[0].url) : "";
return (
<ItemAnchor item={item} onEdit={onEdit}>
{(open) => (
<div
ref={dnd.ref}
style={dnd.style}
{...dnd.attributes}
{...dnd.listeners}
onClick={open}
className={cn(
"group/card relative flex cursor-grab items-center gap-3",
"rounded-[10px] border border-border bg-surface px-3.5 py-3",
"transition-colors hover:border-border-strong hover:bg-surface-hover active:scale-[0.98]",
dnd.isDragging && "opacity-40",
)}
>
<IconImg item={item} size={36} />
<div className="min-w-0">
<div className="truncate text-[13.5px] font-medium tracking-tight">{item.name}</div>
{host ? <div className="truncate text-[11.5px] text-text-faint">{host}</div> : null}
</div>
{item.urls.length > 1 && (
<span className="shrink-0 rounded border border-border px-1 py-px text-[10px] tabular-nums text-text-faint">
{item.urls.length}
</span>
)}
{item.urls.length > 0 && <StatusChip status={status} variant="dot" />}
<button
type="button"
aria-label={`Edit ${item.name}`}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="absolute -right-1 -top-1 rounded-full border border-border bg-popover p-1 text-text-faint opacity-0 transition-opacity hover:text-text group-hover/card:opacity-100"
>
<Pencil size={10} />
</button>
</div>
)}
</ItemAnchor>
);
}
function WidgetView({ item, sectionId }: ItemViewProps) {
const dnd = useItemDnd(sectionId, item.id);
return (
<div
ref={dnd.ref}
style={dnd.style}
{...dnd.attributes}
{...dnd.listeners}
className={cn(
"flex items-center rounded-[10px] border border-dashed border-border-strong bg-surface px-4 py-3",
dnd.isDragging && "opacity-40",
)}
>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-text-faint">
{item.name}
</span>
</div>
);
}
export const cards: RendererViews = {
BoardView,
SectionView,
ItemView,
WidgetView,
itemsGridClass: "grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2.5",
};
+13
View File
@@ -0,0 +1,13 @@
import { bento } from "./bento";
import { cards } from "./cards";
import type { RendererViews } from "./types";
import type { RendererName } from "@/types";
// Renderer registry — settings.renderer picks the active one.
export const rendererList = ["bento", "cards"] as const satisfies readonly RendererName[];
export const renderers: Record<RendererName, RendererViews> = { bento, cards };
export const rendererLabels: Record<RendererName, string> = {
bento: "Bento",
cards: "Cards",
};
+37
View File
@@ -0,0 +1,37 @@
import type { ComponentType, ReactNode } from "react";
import type { Item, Section, Status } from "@/types";
// Renderer contract — same board state, shared dnd/popover/dialog primitives,
// different structure per style. New renderers implement this shape only.
// Dnd stays shared: views call useSectionDnd / useItemDnd hooks internally.
export interface SectionViewProps {
section: Section;
onToggle(): void;
onDelete(): void;
children: ReactNode; // rendered items grid (ItemSortZone output)
}
export interface ItemViewProps {
item: Item;
sectionId: string;
status?: Status | undefined;
onEdit(): void;
}
export interface BoardViewProps {
children: ReactNode; // rendered sections
}
export interface RendererViews {
/** Arrangement wrapper around the section list. */
BoardView: ComponentType<BoardViewProps>;
/** Section chrome: label, count, collapse, drag grip. */
SectionView: ComponentType<SectionViewProps>;
/** Service tile anatomy: icon, name, host, status, launch anchor. */
ItemView: ComponentType<ItemViewProps>;
/** Widget tile anatomy (Phase 2 fills in). */
WidgetView: ComponentType<ItemViewProps>;
/** Tailwind classes for the items grid inside a section. */
itemsGridClass: string;
}
+33
View File
@@ -0,0 +1,33 @@
import type { components, paths } from "@dash/api-client";
// Board entities — generated from openapi.yaml. Never hand-edit shapes here.
export type Section = components["schemas"]["Section"];
export type Item = components["schemas"]["Item"];
export type BoardUrl = components["schemas"]["Url"];
export type UrlInput = components["schemas"]["UrlInput"];
export type ItemInput = components["schemas"]["ItemInput"];
export type ItemPatch = components["schemas"]["ItemPatch"];
export type Status = components["schemas"]["Status"];
export type StatusMap = Record<string, Status>;
export type ExportPayload = components["schemas"]["Export"];
export type Theme = "dark" | "light";
export type RendererName = "bento" | "cards";
/** Wire shape of GET /api/settings — a free-form map we narrow at the boundary. */
export type SettingsWire = NonNullable<
paths["/api/settings"]["get"]["responses"]["200"]["content"]["application/json"]
>;
export interface Prefs {
theme: Theme;
renderer: RendererName;
}
/** Narrow the settings map to typed prefs; unknown/absent keys fall back. */
export function parsePrefs(raw: SettingsWire): Prefs {
return {
theme: raw.theme === "light" ? "light" : "dark",
renderer: raw.renderer === "cards" ? "cards" : "bento",
};
}
+446 -2
View File
@@ -5,8 +5,7 @@ info:
description: >
Single source of truth for the Dash API. The TypeScript client in
packages/api-client is generated from this file — do not hand-write
API types in the frontend. Phase 1 extends this contract with
sections/items/urls CRUD (see docs/specs).
API types in the frontend.
servers:
- url: /
paths:
@@ -27,7 +26,295 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Health"
/api/sections:
get:
operationId: listSections
summary: Full board — sections with nested items and urls, ordered
responses:
"200":
description: Ordered board tree
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Section"
post:
operationId: createSection
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SectionInput"
responses:
"201":
description: Created section
content:
application/json:
schema:
$ref: "#/components/schemas/Section"
/api/sections/{id}:
patch:
operationId: updateSection
parameters:
- $ref: "#/components/parameters/id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SectionPatch"
responses:
"200":
description: Updated section
content:
application/json:
schema:
$ref: "#/components/schemas/Section"
"404":
$ref: "#/components/responses/NotFound"
delete:
operationId: deleteSection
parameters:
- $ref: "#/components/parameters/id"
responses:
"204":
description: Section and its items deleted
/api/sections/reorder:
post:
operationId: reorderSection
summary: Move a section between two neighbors (fractional position)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Reorder"
responses:
"200":
description: Reordered
content:
application/json:
schema:
$ref: "#/components/schemas/Section"
"404":
$ref: "#/components/responses/NotFound"
/api/items:
post:
operationId: createItem
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ItemInput"
responses:
"201":
description: Created item
content:
application/json:
schema:
$ref: "#/components/schemas/Item"
"400":
$ref: "#/components/responses/BadRequest"
/api/items/{id}:
patch:
operationId: updateItem
parameters:
- $ref: "#/components/parameters/id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ItemPatch"
responses:
"200":
description: Updated item
content:
application/json:
schema:
$ref: "#/components/schemas/Item"
"404":
$ref: "#/components/responses/NotFound"
delete:
operationId: deleteItem
parameters:
- $ref: "#/components/parameters/id"
responses:
"204":
description: Item deleted
/api/items/{id}/icon:
post:
operationId: uploadIcon
summary: Upload an icon file; returns the stored icon reference
parameters:
- $ref: "#/components/parameters/id"
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
"200":
description: Icon stored
content:
application/json:
schema:
$ref: "#/components/schemas/Item"
"400":
$ref: "#/components/responses/BadRequest"
/api/items/reorder:
post:
operationId: reorderItem
summary: Move an item between two neighbors, optionally across sections
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ItemReorder"
responses:
"200":
description: Reordered
content:
application/json:
schema:
$ref: "#/components/schemas/Item"
"404":
$ref: "#/components/responses/NotFound"
/api/icons/{file}:
get:
operationId: getIcon
summary: Serve an uploaded icon file
parameters:
- name: file
in: path
required: true
schema:
type: string
responses:
"200":
description: Icon bytes
"404":
$ref: "#/components/responses/NotFound"
/api/status:
get:
operationId: getStatus
summary: Reachability of each service item's primary URL (~60s server cache)
responses:
"200":
description: Status map keyed by item id
content:
application/json:
schema:
type: object
additionalProperties:
$ref: "#/components/schemas/Status"
/api/settings:
get:
operationId: getSettings
responses:
"200":
description: All settings as key/value map
content:
application/json:
schema:
type: object
additionalProperties: true
put:
operationId: putSettings
summary: Merge settings (keys not sent are preserved)
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
"200":
description: Full settings map after merge
content:
application/json:
schema:
type: object
additionalProperties: true
/api/export:
get:
operationId: exportBoard
summary: Full JSON backup (sections, items, urls, settings)
responses:
"200":
description: Board export
content:
application/json:
schema:
$ref: "#/components/schemas/Export"
/api/import:
post:
operationId: importBoard
summary: Replace board state from an export payload
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Export"
responses:
"200":
description: Imported board tree
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Section"
"400":
$ref: "#/components/responses/BadRequest"
components:
parameters:
id:
name: id
in: path
required: true
schema:
type: string
responses:
NotFound:
description: Not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
BadRequest:
description: Invalid input
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
schemas:
Health:
type: object
@@ -36,3 +323,160 @@ components:
status:
type: string
example: ok
Error:
type: object
required: [error]
properties:
error:
type: string
Url:
type: object
required: [id, url, label]
properties:
id:
type: string
url:
type: string
format: uri
label:
type: string
description: Tag such as "local" / "external" or a custom label
position:
type: number
UrlInput:
type: object
required: [url]
properties:
url:
type: string
format: uri
label:
type: string
default: ""
Item:
type: object
required: [id, sectionId, kind, name, position, urls]
properties:
id:
type: string
sectionId:
type: string
kind:
type: string
enum: [service, widget]
name:
type: string
icon:
type: string
description: "/api/icons/<file> path or remote URL; empty = letter tile"
position:
type: number
config:
type: object
nullable: true
additionalProperties: true
description: Widget config (Phase 2); null for services
urls:
type: array
items:
$ref: "#/components/schemas/Url"
ItemInput:
type: object
required: [sectionId, name]
properties:
sectionId:
type: string
name:
type: string
icon:
type: string
urls:
type: array
items:
$ref: "#/components/schemas/UrlInput"
ItemPatch:
type: object
properties:
name:
type: string
icon:
type: string
urls:
type: array
description: Full replacement of the item's URL list
items:
$ref: "#/components/schemas/UrlInput"
Section:
type: object
required: [id, name, position, collapsed, items]
properties:
id:
type: string
name:
type: string
position:
type: number
collapsed:
type: boolean
items:
type: array
items:
$ref: "#/components/schemas/Item"
SectionInput:
type: object
required: [name]
properties:
name:
type: string
SectionPatch:
type: object
properties:
name:
type: string
collapsed:
type: boolean
Reorder:
type: object
required: [id]
properties:
id:
type: string
beforeId:
type: string
description: Neighbor now before the moved entity; null if first
afterId:
type: string
description: Neighbor now after the moved entity; null if last
ItemReorder:
allOf:
- $ref: "#/components/schemas/Reorder"
- type: object
properties:
sectionId:
type: string
description: Target section (defaults to current)
Status:
type: object
required: [status]
properties:
status:
type: string
enum: [up, down]
latencyMs:
type: integer
checkedAt:
type: string
format: date-time
Export:
type: object
required: [version, sections]
properties:
version:
type: integer
settings:
type: object
additionalProperties: true
sections:
type: array
items:
$ref: "#/components/schemas/Section"
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
".agents/**",
".claude/**",
"tools/oxlint/anti-slop/**",
"packages/api-client/src/schema.d.ts",
"**/dist/**",
"**/node_modules/**",
],
+872 -5
View File
@@ -20,13 +20,23 @@
"version": "0.0.0",
"dependencies": {
"@dash/api-client": "*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.2.5",
"@fontsource-variable/geist-mono": "^5.2.5",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.13",
@@ -329,6 +339,59 @@
"resolved": "apps/frontend",
"link": true
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -771,6 +834,44 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@fontsource-variable/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
@@ -1219,6 +1320,606 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
"integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
"integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz",
"integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.10"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",
"integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-slot": "1.3.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
"integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.23",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz",
"integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-dismissable-layer": "1.1.19",
"@radix-ui/react-focus-guards": "1.1.6",
"@radix-ui/react-focus-scope": "1.1.16",
"@radix-ui/react-id": "1.1.4",
"@radix-ui/react-portal": "1.1.17",
"@radix-ui/react-presence": "1.1.10",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-layout-effect": "1.1.4",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
"integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
"integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-effect-event": "0.0.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
"integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
"integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz",
"integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.10"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.23",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz",
"integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-dismissable-layer": "1.1.19",
"@radix-ui/react-focus-guards": "1.1.6",
"@radix-ui/react-focus-scope": "1.1.16",
"@radix-ui/react-id": "1.1.4",
"@radix-ui/react-popper": "1.3.7",
"@radix-ui/react-portal": "1.1.17",
"@radix-ui/react-presence": "1.1.10",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-use-controllable-state": "1.2.6",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz",
"integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.15",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-layout-effect": "1.1.4",
"@radix-ui/react-use-rect": "1.1.4",
"@radix-ui/react-use-size": "1.1.4",
"@radix-ui/rect": "1.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
"integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
"integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
"integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.3.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz",
"integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.3",
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-collection": "1.1.15",
"@radix-ui/react-compose-refs": "1.1.5",
"@radix-ui/react-context": "1.2.2",
"@radix-ui/react-direction": "1.1.4",
"@radix-ui/react-dismissable-layer": "1.1.19",
"@radix-ui/react-focus-guards": "1.1.6",
"@radix-ui/react-focus-scope": "1.1.16",
"@radix-ui/react-id": "1.1.4",
"@radix-ui/react-popper": "1.3.7",
"@radix-ui/react-portal": "1.1.17",
"@radix-ui/react-presence": "1.1.10",
"@radix-ui/react-primitive": "2.1.10",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-use-callback-ref": "1.1.4",
"@radix-ui/react-use-controllable-state": "1.2.6",
"@radix-ui/react-use-layout-effect": "1.1.4",
"@radix-ui/react-use-previous": "1.1.4",
"@radix-ui/react-visually-hidden": "1.2.11",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
"integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.5"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
"integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
"integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.7",
"@radix-ui/react-use-effect-event": "0.0.5",
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz",
"integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz",
"integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.3"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz",
"integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.4"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz",
"integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.10"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz",
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"license": "MIT"
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -2011,14 +2712,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2029,7 +2730,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -2083,6 +2784,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2175,6 +2888,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"license": "Apache-2.0",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2202,7 +2927,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -2233,6 +2958,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.430",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz",
@@ -2356,6 +3087,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -3032,6 +3772,75 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3178,6 +3987,21 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
@@ -3250,6 +4074,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",