First go at it :) #1

Merged
atridad merged 12 commits from dev into main 2026-05-02 02:01:53 -06:00
17 changed files with 254 additions and 25 deletions
Showing only changes of commit 7420e2b890 - Show all commits
+1
View File
@@ -0,0 +1 @@
ROOT_DIR=./data
+5 -4
View File
@@ -1,14 +1,15 @@
# binary
sprintpadawan
server
main
# sqlite db
# db
app.db
app.db-wal
# env
.env
# os
.DS_Store
Thumbs.db
app.db-wal
server
.direnv/
+21
View File
@@ -0,0 +1,21 @@
# Build stage
FROM golang:alpine AS builder
RUN apk add --no-cache build-base
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o sprintpadawan main.go
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
ENV ROOT_DIR=/data
RUN mkdir -p /data
COPY --from=builder /app/sprintpadawan .
EXPOSE 8080
CMD ["./sprintpadawan"]
+28 -1
View File
@@ -1,2 +1,29 @@
# sprintpadawan
# SprintPadawan
A lightweight real-time sprint planning tool. Built with Go, HTMX, and Turso (The FOSS DB, not the platform).
## Development
This project uses Go 1.26. If you use Nix, a flake is provided to set up the environment. You can load it with `nix develop` or `direnv allow`.
Available Make commands:
* `make dev`: Runs the development server.
* `make build`: Compiles everything into a single binary.
* `make clean`: Removes the binary.
When running locally without Docker, the application will create an `app.db` SQLite database file in your current working directory.
## Docker
The project includes a Docker setup for those who use it.
1. Create a `.env` file in the project root.
2. Set the `ROOT_DIR` variable to the directory on your host machine where you want the database to be saved.
Example `.env`:
ROOT_DIR=/home/user/sprintpadawan_data
3. Start the container:
docker compose up -d
The Docker container maps your host `ROOT_DIR` to `/data` inside the container. Sprint Padawan is permanently configured to write its database to `/data` when running in Docker.
+1
View File
@@ -0,0 +1 @@
0.1.0
+9
View File
@@ -0,0 +1,9 @@
services:
app:
build: .
container_name: sprintpadawan
ports:
- "8080:8080"
volumes:
- ${ROOT_DIR:-./data}:/data
restart: unless-stopped
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1777268161,
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+1
View File
@@ -10,6 +10,7 @@ require (
require (
github.com/ebitengine/purego v0.9.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/tursodatabase/turso-go-platform-libs v0.5.3 // indirect
golang.org/x/sys v0.43.0 // indirect
)
+2
View File
@@ -4,6 +4,8 @@ github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+10 -2
View File
@@ -3,16 +3,24 @@ package lib
import (
"database/sql"
"log"
"os"
"path/filepath"
_ "turso.tech/database/tursogo"
)
var DB *sql.DB
// init sqlite db — always creates app.db at project root (run from root)
// init sqlite db — creates app.db at project root (run from root) or ROOT_DIR if set
func InitDB() {
var err error
DB, err = sql.Open("turso", "app.db")
dbPath := "app.db"
if rootDir := os.Getenv("ROOT_DIR"); rootDir != "" {
dbPath = filepath.Join(rootDir, "app.db")
}
DB, err = sql.Open("turso", dbPath)
if err != nil {
log.Fatal(err)
}
+44 -1
View File
@@ -1,25 +1,68 @@
package main
import (
"compress/gzip"
"embed"
"io"
"log"
"net/http"
"strings"
"github.com/joho/godotenv"
"sprintpadawan/api"
"sprintpadawan/lib"
)
type gzipResponseWriter struct {
io.Writer
http.ResponseWriter
}
func (w gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}
func gzipMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
gz := gzip.NewWriter(w)
defer gz.Close()
gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w}
next.ServeHTTP(gzw, r)
})
}
func cacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000")
next.ServeHTTP(w, r)
})
}
//go:embed static templates
var embeddedFiles embed.FS
func main() {
// load .env file if it exists
_ = godotenv.Load()
lib.InitDB()
api.InitTemplates(embeddedFiles)
mux := http.NewServeMux()
// serve static assets
mux.Handle("/static/", http.FileServer(http.FS(embeddedFiles)))
staticHandler := http.FileServer(http.FS(embeddedFiles))
mux.Handle("/static/", cacheMiddleware(gzipMiddleware(staticHandler)))
api.SetupRoutes(mux)
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

+55 -11
View File
@@ -3,6 +3,28 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}} — SprintPadawan</title>
<meta
name="description"
content="A lightweight real-time sprint planning tool."
/>
<meta property="og:title" content="{{.Title}} - SprintPadawan" />
<meta
property="og:description"
content="A lightweight real-time sprint planning tool."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="/static/img/logo.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{.Title}} - SprintPadawan" />
<meta
name="twitter:description"
content="A lightweight real-time sprint planning tool."
/>
<meta name="twitter:image" content="/static/img/logo.png" />
<meta name="theme-color" content="#6366f1" />
<link rel="icon" href="/static/img/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/static/img/logo.webp" />
<link rel="apple-touch-icon" href="/static/img/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
@@ -16,13 +38,33 @@
<script src="/static/js/sse.js" defer></script>
{{end}}
</head>
{{end}}
{{define "auth-head"}}
{{end}} {{define "auth-head"}}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}} — SprintPadawan</title>
<meta
name="description"
content="A lightweight web application for agile sprint planning."
/>
<meta property="og:title" content="{{.Title}} — SprintPadawan" />
<meta
property="og:description"
content="A lightweight web application for agile sprint planning."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="/static/img/logo.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{.Title}} — SprintPadawan" />
<meta
name="twitter:description"
content="A lightweight web application for agile sprint planning."
/>
<meta name="twitter:image" content="/static/img/logo.png" />
<meta name="theme-color" content="#6366f1" />
<link rel="icon" href="/static/img/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/static/img/logo.webp" />
<link rel="apple-touch-icon" href="/static/img/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
@@ -31,16 +73,18 @@
/>
<link rel="stylesheet" href="/static/styles/main.css" />
</head>
{{end}}
{{define "brand-mark"}}
{{end}} {{define "brand-mark"}}
<a href="/" class="brand-mark">
<span class="logo-icon"></span>
<span class="logo-text"><span>Sprint</span>Padawan</span>
<img
src="/static/img/logo.webp"
alt="SprintPadawan"
height="32"
width="32"
fetchpriority="high"
decoding="async"
/>
</a>
{{end}}
{{define "session-controls"}}
{{end}} {{define "session-controls"}}
<div class="topbar-user">
<div class="user-avatar">{{slice .Username 0 1}}</div>
<span class="topbar-user-name">{{.Username}}</span>
+8 -3
View File
@@ -4,10 +4,15 @@
<body>
<div class="auth-card">
<div class="auth-logo">
<div class="logo-icon"></div>
<img
src="/static/img/logo.webp"
alt="SprintPadawan"
height="56"
width="56"
fetchpriority="high"
decoding="async"
/>
</div>
<h1 class="auth-title"><span>Sprint</span>Padawan</h1>
<p class="auth-subtitle">Sign in to your account</p>
{{if .Error}}
+8 -3
View File
@@ -4,10 +4,15 @@
<body>
<div class="auth-card">
<div class="auth-logo">
<div class="logo-icon"></div>
<img
src="/static/img/logo.webp"
alt="SprintPadawan"
height="56"
width="56"
fetchpriority="high"
decoding="async"
/>
</div>
<h1 class="auth-title"><span>Sprint</span>Padawan</h1>
<p class="auth-subtitle">Create your account</p>
{{if .Error}}