Init work
This commit is contained in:
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