Init work
This commit is contained in:
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM golang:1.23-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY main.go .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /todo-glance .
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
COPY --from=build /todo-glance /todo-glance
|
||||||
|
VOLUME /data
|
||||||
|
ENV TODO_DB_PATH=/data/todos.db
|
||||||
|
EXPOSE 8081
|
||||||
|
ENTRYPOINT ["/todo-glance"]
|
||||||
110
README.md
110
README.md
@@ -0,0 +1,110 @@
|
|||||||
|
# Todo-Glance
|
||||||
|
|
||||||
|
A server-backed CRUD todo widget for [Glance](https://github.com/glanceapp/glance). Stores tasks in SQLite instead of browser localStorage, so your todos persist across devices and sessions.
|
||||||
|
|
||||||
|
Integrates as a Glance Extension widget with native theming via Glance CSS variables.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Go** — single `main.go`, no frameworks
|
||||||
|
- **SQLite** via [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) — pure Go, no CGO required
|
||||||
|
- **Vanilla JS** — embedded in the Go binary, no build step
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker Compose (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Glance dashboard: `http://localhost:8080`
|
||||||
|
- Todo API: `http://localhost:8081`
|
||||||
|
|
||||||
|
The included `glance.yml` has the widget pre-configured. Edit it to add your other Glance widgets.
|
||||||
|
|
||||||
|
### Standalone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o todo-glance .
|
||||||
|
./todo-glance
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the widget to your existing Glance config:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- type: extension
|
||||||
|
url: http://todo-glance:8081/
|
||||||
|
allow-potentially-dangerous-html: true
|
||||||
|
cache: 1s
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `todo-glance` with the hostname or IP where the server is reachable from Glance.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `TODO_PORT` | `8081` | Server listen port |
|
||||||
|
| `TODO_DB_PATH` | `./todos.db` | SQLite database file path |
|
||||||
|
| `TODO_EXTERNAL_URL` | *(from Host header)* | URL browsers use to reach the API. Required when the browser-facing URL differs from the internal one (e.g. behind a reverse proxy). |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/` | Extension widget HTML (returns `Widget-Title` and `Widget-Content-Type` headers) |
|
||||||
|
| `GET` | `/api/todos` | List all todos |
|
||||||
|
| `POST` | `/api/todos` | Create a todo — `{"text": "...", "priority": 0-3, "tags": [...]}` |
|
||||||
|
| `PUT` | `/api/todos/{id}` | Update a todo — `{"text": "...", "done": true, "priority": 0-3, "tags": [...]}` |
|
||||||
|
| `DELETE` | `/api/todos/{id}` | Delete a todo — returns 204 |
|
||||||
|
|
||||||
|
Priority levels: `0` = none, `1` = low, `2` = medium, `3` = high. Todos are sorted by: incomplete first, then highest priority, then newest.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create with priority and tags
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"text":"Fix login bug","priority":3,"tags":["work","urgent"]}' \
|
||||||
|
http://localhost:8081/api/todos
|
||||||
|
|
||||||
|
# Create simple
|
||||||
|
curl -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"text":"Buy milk"}' http://localhost:8081/api/todos
|
||||||
|
|
||||||
|
# List (sorted by priority)
|
||||||
|
curl http://localhost:8081/api/todos
|
||||||
|
|
||||||
|
# Mark done
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-d '{"done":true}' http://localhost:8081/api/todos/1
|
||||||
|
|
||||||
|
# Change priority
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-d '{"priority":2}' http://localhost:8081/api/todos/1
|
||||||
|
|
||||||
|
# Update tags
|
||||||
|
curl -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-d '{"tags":["work","reviewed"]}' http://localhost:8081/api/todos/1
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
curl -X DELETE http://localhost:8081/api/todos/1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (Glance page)
|
||||||
|
│
|
||||||
|
│ GET / Glance fetches widget HTML from todo-glance
|
||||||
|
│ ─────────────► (internal Docker network)
|
||||||
|
│
|
||||||
|
│ API calls JS in the widget calls /api/* endpoints
|
||||||
|
│ ─────────────► (browser hits TODO_EXTERNAL_URL / localhost:8081)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
todo-glance ──► SQLite file (/data/todos.db in Docker)
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTML is served to Glance over the internal network. Once rendered in the browser, the embedded JS makes API calls directly to the todo-glance server using the external URL. CORS headers are set on all `/api/` endpoints to allow this cross-origin access.
|
||||||
|
|||||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
glance:
|
||||||
|
image: glanceapp/glance
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./glance.yml:/app/glance.yml
|
||||||
|
depends_on:
|
||||||
|
- todo-glance
|
||||||
|
|
||||||
|
todo-glance:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8081:8081"
|
||||||
|
volumes:
|
||||||
|
- todo-data:/data
|
||||||
|
environment:
|
||||||
|
TODO_EXTERNAL_URL: "http://localhost:8081"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
todo-data:
|
||||||
9
glance.yml
Normal file
9
glance.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pages:
|
||||||
|
- name: Home
|
||||||
|
columns:
|
||||||
|
- size: full
|
||||||
|
widgets:
|
||||||
|
- type: extension
|
||||||
|
url: http://todo-glance:8081/
|
||||||
|
allow-potentially-dangerous-html: true
|
||||||
|
cache: 1s
|
||||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
module todo-glance
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.46.1 // indirect
|
||||||
|
)
|
||||||
23
go.sum
Normal file
23
go.sum
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
603
main.go
Normal file
603
main.go
Normal file
@@ -0,0 +1,603 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Todo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var db *sql.DB
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTags(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var tags []string
|
||||||
|
for _, t := range strings.Split(s, ",") {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t != "" && !seen[t] {
|
||||||
|
seen[t] = true
|
||||||
|
tags = append(tags, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinTags(tags []string) string {
|
||||||
|
var cleaned []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, t := range tags {
|
||||||
|
t = strings.TrimSpace(t)
|
||||||
|
if t != "" && !seen[t] {
|
||||||
|
seen[t] = true
|
||||||
|
cleaned = append(cleaned, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(cleaned, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTodo(row interface{ Scan(...any) error }) (Todo, error) {
|
||||||
|
var t Todo
|
||||||
|
var done int
|
||||||
|
var tags string
|
||||||
|
err := row.Scan(&t.ID, &t.Text, &done, &t.Priority, &tags, &t.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
t.Done = done == 1
|
||||||
|
t.Tags = parseTags(tags)
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCols = "id, text, done, priority, tags, created_at"
|
||||||
|
|
||||||
|
func initDB(path string) {
|
||||||
|
var err error
|
||||||
|
db, err = sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS todos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
done INTEGER NOT NULL DEFAULT 0,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tags TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
// migrate older schemas
|
||||||
|
db.Exec("ALTER TABLE todos ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.Exec("ALTER TABLE todos ADD COLUMN tags TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cors(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWidget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
baseURL := env("TODO_EXTERNAL_URL", "")
|
||||||
|
if baseURL == "" {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
baseURL = scheme + "://" + r.Host
|
||||||
|
}
|
||||||
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
|
||||||
|
w.Header().Set("Widget-Title", "Todos")
|
||||||
|
w.Header().Set("Widget-Content-Type", "html")
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
fmt.Fprintf(w, widgetHTML, baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListTodos(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := db.Query("SELECT " + selectCols + " FROM todos ORDER BY done ASC, priority DESC, created_at DESC")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
todos := []Todo{}
|
||||||
|
for rows.Next() {
|
||||||
|
t, err := scanTodo(rows)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
todos = append(todos, t)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, todos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateTodo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Priority *int `json:"priority"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.Text = strings.TrimSpace(input.Text)
|
||||||
|
if input.Text == "" {
|
||||||
|
http.Error(w, "text is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
priority := 0
|
||||||
|
if input.Priority != nil {
|
||||||
|
priority = clampPriority(*input.Priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.Exec(
|
||||||
|
"INSERT INTO todos (text, priority, tags) VALUES (?, ?, ?)",
|
||||||
|
input.Text, priority, joinTags(input.Tags),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
t, err := scanTodo(db.QueryRow("SELECT "+selectCols+" FROM todos WHERE id = ?", id))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateTodo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input struct {
|
||||||
|
Text *string `json:"text"`
|
||||||
|
Done *bool `json:"done"`
|
||||||
|
Priority *int `json:"priority"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Text != nil {
|
||||||
|
if _, err := db.Exec("UPDATE todos SET text = ? WHERE id = ?", strings.TrimSpace(*input.Text), id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.Done != nil {
|
||||||
|
done := 0
|
||||||
|
if *input.Done {
|
||||||
|
done = 1
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("UPDATE todos SET done = ? WHERE id = ?", done, id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.Priority != nil {
|
||||||
|
if _, err := db.Exec("UPDATE todos SET priority = ? WHERE id = ?", clampPriority(*input.Priority), id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.Tags != nil {
|
||||||
|
if _, err := db.Exec("UPDATE todos SET tags = ? WHERE id = ?", joinTags(input.Tags), id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := scanTodo(db.QueryRow("SELECT "+selectCols+" FROM todos WHERE id = ?", id))
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteTodo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.Exec("DELETE FROM todos WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampPriority(p int) int {
|
||||||
|
if p < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if p > 3 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := env("TODO_PORT", "8081")
|
||||||
|
dbPath := env("TODO_DB_PATH", "./todos.db")
|
||||||
|
|
||||||
|
initDB(dbPath)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /{$}", handleWidget)
|
||||||
|
mux.HandleFunc("GET /api/todos", handleListTodos)
|
||||||
|
mux.HandleFunc("POST /api/todos", handleCreateTodo)
|
||||||
|
mux.HandleFunc("PUT /api/todos/{id}", handleUpdateTodo)
|
||||||
|
mux.HandleFunc("DELETE /api/todos/{id}", handleDeleteTodo)
|
||||||
|
|
||||||
|
log.Printf("todo-glance listening on :%s", port)
|
||||||
|
log.Fatal(http.ListenAndServe(":"+port, cors(mux)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetHTML = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
*{ box-sizing:border-box; margin:0; padding:0; }
|
||||||
|
|
||||||
|
.todo-container {
|
||||||
|
font-family: var(--font, sans-serif);
|
||||||
|
color: var(--color-text-base, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form .todo-form-top {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--color-separator, #374151);
|
||||||
|
background: var(--color-widget-background-highlight, #1e293b);
|
||||||
|
color: var(--color-text-base, #e2e8f0);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form input[type="text"]:focus {
|
||||||
|
border-color: var(--color-primary, #6c9fe8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form select {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--color-separator, #374151);
|
||||||
|
background: var(--color-widget-background-highlight, #1e293b);
|
||||||
|
color: var(--color-text-base, #e2e8f0);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-primary, #6c9fe8);
|
||||||
|
color: var(--color-widget-background, #0f172a);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form button:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form .todo-form-bottom {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form .todo-form-bottom input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--color-separator, #374151);
|
||||||
|
background: var(--color-widget-background-highlight, #1e293b);
|
||||||
|
color: var(--color-text-base, #e2e8f0);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-form .todo-form-bottom input:focus {
|
||||||
|
border-color: var(--color-primary, #6c9fe8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--color-separator, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--color-primary, #6c9fe8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item .priority-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item .priority-dot.p3 { background: var(--color-negative, #ef4444); }
|
||||||
|
.todo-item .priority-dot.p2 { background: #eab308; }
|
||||||
|
.todo-item .priority-dot.p1 { background: var(--color-primary, #6c9fe8); }
|
||||||
|
|
||||||
|
.todo-item .todo-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item .todo-text {
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item.done .todo-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item .todo-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--color-separator, #374151);
|
||||||
|
color: var(--color-text-subdue, #94a3b8);
|
||||||
|
border-radius: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item .todo-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item .delete-btn,
|
||||||
|
.todo-item .priority-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-subdue, #64748b);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item:hover .delete-btn,
|
||||||
|
.todo-item:hover .priority-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item .delete-btn:hover {
|
||||||
|
color: var(--color-negative, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-item .priority-btn:hover {
|
||||||
|
color: var(--color-primary, #6c9fe8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-empty {
|
||||||
|
color: var(--color-text-subdue, #64748b);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="todo-container">
|
||||||
|
<form class="todo-form" onsubmit="addTodo(event)">
|
||||||
|
<div class="todo-form-top">
|
||||||
|
<input type="text" id="todo-input" placeholder="Add a task..." autocomplete="off">
|
||||||
|
<select id="todo-priority">
|
||||||
|
<option value="0">—</option>
|
||||||
|
<option value="1">Low</option>
|
||||||
|
<option value="2">Med</option>
|
||||||
|
<option value="3">High</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="todo-form-bottom">
|
||||||
|
<input type="text" id="todo-tags" placeholder="Tags (comma separated)" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ul class="todo-list" id="todo-list"></ul>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const API = "%s/api/todos";
|
||||||
|
const list = document.getElementById("todo-list");
|
||||||
|
const inputText = document.getElementById("todo-input");
|
||||||
|
const inputPriority = document.getElementById("todo-priority");
|
||||||
|
const inputTags = document.getElementById("todo-tags");
|
||||||
|
|
||||||
|
const PRIORITY_LABELS = ["", "Low", "Med", "High"];
|
||||||
|
|
||||||
|
async function loadTodos() {
|
||||||
|
const res = await fetch(API);
|
||||||
|
const todos = await res.json();
|
||||||
|
list.innerHTML = "";
|
||||||
|
if (todos.length === 0) {
|
||||||
|
list.innerHTML = '<li class="todo-empty">No tasks yet</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
todos.forEach(t => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "todo-item" + (t.done ? " done" : "");
|
||||||
|
|
||||||
|
let dot = "";
|
||||||
|
if (t.priority > 0) {
|
||||||
|
dot = '<span class="priority-dot p' + t.priority + '" title="' + PRIORITY_LABELS[t.priority] + ' priority"></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagsHTML = "";
|
||||||
|
if (t.tags && t.tags.length > 0) {
|
||||||
|
tagsHTML = '<div class="todo-tags">' + t.tags.map(tag => '<span class="todo-tag">' + esc(tag) + '</span>').join("") + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
li.innerHTML =
|
||||||
|
'<input type="checkbox"' + (t.done ? " checked" : "") + ' onchange="toggleTodo(' + t.id + ', this.checked)">' +
|
||||||
|
dot +
|
||||||
|
'<div class="todo-content"><span class="todo-text">' + esc(t.text) + '</span>' + tagsHTML + '</div>' +
|
||||||
|
'<div class="todo-actions">' +
|
||||||
|
'<button class="priority-btn" onclick="cyclePriority(' + t.id + ',' + t.priority + ')" title="Change priority">▲</button>' +
|
||||||
|
'<button class="delete-btn" onclick="deleteTodo(' + t.id + ')">×</button>' +
|
||||||
|
'</div>';
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTodo(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = inputText.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const priority = parseInt(inputPriority.value, 10);
|
||||||
|
const tags = inputTags.value.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
|
await fetch(API, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({text, priority, tags})
|
||||||
|
});
|
||||||
|
inputText.value = "";
|
||||||
|
inputPriority.value = "0";
|
||||||
|
inputTags.value = "";
|
||||||
|
loadTodos();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTodo(id, done) {
|
||||||
|
await fetch(API + "/" + id, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({done})
|
||||||
|
});
|
||||||
|
loadTodos();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cyclePriority(id, current) {
|
||||||
|
const next = (current + 1) %% 4;
|
||||||
|
await fetch(API + "/" + id, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({priority: next})
|
||||||
|
});
|
||||||
|
loadTodos();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTodo(id) {
|
||||||
|
await fetch(API + "/" + id, {method: "DELETE"});
|
||||||
|
loadTodos();
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTodos();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
Reference in New Issue
Block a user