Init work

This commit is contained in:
2026-03-09 08:19:51 +01:00
parent 43f183bb7c
commit 48cf5b96b7
7 changed files with 796 additions and 0 deletions

13
Dockerfile Normal file
View 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
View File

@@ -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
View 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
View 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
View 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
View 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
View 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">&#9650;</button>' +
'<button class="delete-btn" onclick="deleteTodo(' + t.id + ')">&times;</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>`