feat: phase 0 foundation — monorepo scaffold, backend, frontend, CI, docker

Establishes the skeleton every later phase builds on: Go+Gin API with
zap logging, /api/healthz, SQLite+goose runner; Vite+React18+TS strict+
Tailwind v4 frontend with bento-mono theme tokens; openapi.yaml driving
a generated TS client; oxlint with vendored anti-slop rules; GitHub CI;
multi-stage Dockerfile -> 33.6MB distroless image; docker compose up
serves the blank board.


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:21 +02:00
1 parent e7f32949e9
commit b60821350a
46 files changed
+6415 -10

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
// Package api wires the Gin router and /api handlers.
package api
import (
"database/sql"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
func NewRouter(logger *zap.Logger, db *sql.DB) *gin.Engine {
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"})
})
return r
}
// accessLog is gin.Logger backed by zap: one structured line per request.
func accessLog(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
logger.Info("request",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("latency", time.Since(start)),
)
}
}
+33
View File
@@ -0,0 +1,33 @@
// Package db opens the SQLite database and applies goose migrations.
package db
import (
"database/sql"
"embed"
"fmt"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrations embed.FS
// Open opens (creating if needed) the SQLite file at path and migrates it
// to the latest schema. WAL + busy_timeout keep reads snappy under writes.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
goose.SetBaseFS(migrations)
if err := goose.SetDialect("sqlite3"); err != nil {
return nil, fmt.Errorf("goose dialect: %w", err)
}
if err := goose.Up(sqlDB, "migrations"); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
return sqlDB, nil
}
@@ -0,0 +1,9 @@
-- +goose Up
-- Key/value store for user prefs (theme, renderer, ...). Values are JSON.
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value JSON NOT NULL
);
-- +goose Down
DROP TABLE settings;
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><title>Dash</title><p>Dash API is running. Frontend build is not embedded in this binary.</p>
+41
View File
@@ -0,0 +1,41 @@
// Package web serves the embedded frontend build (SPA with index.html
// fallback). dist/ is populated by the frontend build in Docker/CI; the
// committed .keep keeps //go:embed satisfied on a bare clone.
package web
import (
"embed"
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
)
//go:embed all:dist
var dist embed.FS
// Register mounts the SPA on the router. Non-/api paths serve static files;
// unknown paths fall back to index.html for client-side routing.
func Register(r *gin.Engine) error {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return err
}
fileServer := http.FileServer(http.FS(sub))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if len(path) >= 4 && path[:4] == "/api" {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if f, err := sub.Open(path[1:]); err == nil {
_ = f.Close()
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
c.Request.URL.Path = "/"
fileServer.ServeHTTP(c.Writer, c.Request)
})
return nil
}