The attodo.app, uhh... app.

list view

+1595 -4
+7 -1
cmd/server/main.go
··· 22 authHandler := handlers.NewAuthHandler(cfg) 23 authMiddleware := middleware.NewAuthMiddleware(authHandler) 24 taskHandler := handlers.NewTaskHandler(authHandler.Client()) 25 26 // Initialize templates 27 handlers.InitTemplates() ··· 37 38 // Public routes 39 mux.HandleFunc("/", handleLanding(authHandler)) 40 - mux.HandleFunc("/client-metadata.json", authHandler.Client().ClientMetadataHandler()) 41 mux.HandleFunc("/login", authHandler.HandleLogin) 42 mux.HandleFunc("/callback", authHandler.Client().CallbackHandler(authHandler.CallbackSuccess)) 43 mux.HandleFunc("/logout", authHandler.Logout) ··· 50 // Protected routes 51 mux.Handle("/app", authMiddleware.RequireAuth(http.HandlerFunc(handleDashboard))) 52 mux.Handle("/app/tasks", authMiddleware.RequireAuth(http.HandlerFunc(taskHandler.HandleTasks))) 53 54 // Start server 55 log.Printf("Starting server on :%s", cfg.Port)
··· 22 authHandler := handlers.NewAuthHandler(cfg) 23 authMiddleware := middleware.NewAuthMiddleware(authHandler) 24 taskHandler := handlers.NewTaskHandler(authHandler.Client()) 25 + listHandler := handlers.NewListHandler(authHandler.Client()) 26 + 27 + // Wire up cross-references between handlers 28 + taskHandler.SetListHandler(listHandler) 29 30 // Initialize templates 31 handlers.InitTemplates() ··· 41 42 // Public routes 43 mux.HandleFunc("/", handleLanding(authHandler)) 44 + mux.HandleFunc("/oauth-client-metadata.json", authHandler.Client().ClientMetadataHandler()) 45 mux.HandleFunc("/login", authHandler.HandleLogin) 46 mux.HandleFunc("/callback", authHandler.Client().CallbackHandler(authHandler.CallbackSuccess)) 47 mux.HandleFunc("/logout", authHandler.Logout) ··· 54 // Protected routes 55 mux.Handle("/app", authMiddleware.RequireAuth(http.HandlerFunc(handleDashboard))) 56 mux.Handle("/app/tasks", authMiddleware.RequireAuth(http.HandlerFunc(taskHandler.HandleTasks))) 57 + mux.Handle("/app/lists", authMiddleware.RequireAuth(http.HandlerFunc(listHandler.HandleLists))) 58 + mux.Handle("/app/lists/view/", authMiddleware.RequireAuth(http.HandlerFunc(listHandler.HandleListDetail))) 59 60 // Start server 61 log.Printf("Starting server on :%s", cfg.Port)
+1 -1
go.mod
··· 6 github.com/bluesky-social/indigo v0.0.0-20251029012702-8c31d8b88187 7 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a 8 github.com/joho/godotenv v1.5.1 9 - github.com/shindakun/bskyoauth v1.3.3 10 ) 11 12 require (
··· 6 github.com/bluesky-social/indigo v0.0.0-20251029012702-8c31d8b88187 7 github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a 8 github.com/joho/godotenv v1.5.1 9 + github.com/shindakun/bskyoauth v1.4.0 10 ) 11 12 require (
+2
go.sum
··· 130 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 131 github.com/shindakun/bskyoauth v1.3.3 h1:JvSvi+SLJROoRKU92OrUXtdPcDv6opslO65msT1ndAA= 132 github.com/shindakun/bskyoauth v1.3.3/go.mod h1:OBeyXPUQL+3R3vWrLS6c44XnI0jQZ+H0/djvyOVwqdQ= 133 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 134 github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 135 github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
··· 130 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 131 github.com/shindakun/bskyoauth v1.3.3 h1:JvSvi+SLJROoRKU92OrUXtdPcDv6opslO65msT1ndAA= 132 github.com/shindakun/bskyoauth v1.3.3/go.mod h1:OBeyXPUQL+3R3vWrLS6c44XnI0jQZ+H0/djvyOVwqdQ= 133 + github.com/shindakun/bskyoauth v1.4.0 h1:Ityt5W10uzrO2yX0D5c5cPGrz07Gd4VWnqC+QMthQ6E= 134 + github.com/shindakun/bskyoauth v1.4.0/go.mod h1:rCvGsSoRYHajK6IIz1q1Dglb6QgmSYCyWsmLM99RMTQ= 135 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 136 github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 137 github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
+1 -1
internal/handlers/auth.go
··· 18 client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{ 19 BaseURL: cfg.BaseURL, 20 ClientName: cfg.ClientName, 21 - Scopes: []string{"atproto", "repo:app.bsky.feed.post?action=create", "repo:app.attodo.task", "account:email?action=read"}, 22 }) 23 24 return &AuthHandler{
··· 18 client := bskyoauth.NewClientWithOptions(bskyoauth.ClientOptions{ 19 BaseURL: cfg.BaseURL, 20 ClientName: cfg.ClientName, 21 + Scopes: []string{"atproto", "repo:app.bsky.feed.post?action=create", "repo:app.attodo.task", "repo:app.attodo.list", "account:email?action=read"}, 22 }) 23 24 return &AuthHandler{
+807
internal/handlers/lists.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "strings" 11 + "time" 12 + 13 + "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/atproto/identity" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "github.com/shindakun/attodo/internal/models" 17 + "github.com/shindakun/attodo/internal/session" 18 + "github.com/shindakun/bskyoauth" 19 + ) 20 + 21 + const ListCollection = "app.attodo.list" 22 + 23 + type ListHandler struct { 24 + client *bskyoauth.Client 25 + } 26 + 27 + func NewListHandler(client *bskyoauth.Client) *ListHandler { 28 + return &ListHandler{client: client} 29 + } 30 + 31 + // HandleLists handles list CRUD operations 32 + func (h *ListHandler) HandleLists(w http.ResponseWriter, r *http.Request) { 33 + switch r.Method { 34 + case http.MethodGet: 35 + h.handleListLists(w, r) 36 + case http.MethodPost: 37 + h.handleCreateList(w, r) 38 + case http.MethodPut: 39 + h.handleUpdateList(w, r) 40 + case http.MethodDelete: 41 + h.handleDeleteList(w, r) 42 + case http.MethodPatch: 43 + h.handleManageTasks(w, r) 44 + default: 45 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 46 + } 47 + } 48 + 49 + // HandleListDetail shows a specific list with its tasks 50 + func (h *ListHandler) HandleListDetail(w http.ResponseWriter, r *http.Request) { 51 + sess, ok := session.GetSession(r) 52 + if !ok { 53 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 54 + return 55 + } 56 + 57 + // Extract rkey from URL path (e.g., /app/lists/view/abc123) 58 + path := strings.TrimPrefix(r.URL.Path, "/app/lists/view/") 59 + rkey := strings.TrimSuffix(path, "/") 60 + 61 + if rkey == "" { 62 + http.Error(w, "List ID required", http.StatusBadRequest) 63 + return 64 + } 65 + 66 + // Get the list 67 + log.Printf("Fetching list with rkey: %s", rkey) 68 + var record map[string]interface{} 69 + var err error 70 + sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 71 + record, err = h.getRecord(r.Context(), s, rkey) 72 + return err 73 + }) 74 + 75 + if err != nil { 76 + log.Printf("Failed to get list rkey=%s: %v", rkey, err) 77 + http.Error(w, fmt.Sprintf("List not found: %v", err), http.StatusNotFound) 78 + return 79 + } 80 + 81 + log.Printf("Successfully fetched list record: %+v", record) 82 + 83 + // Parse list 84 + list := parseListRecord(record) 85 + list.RKey = rkey 86 + list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey) 87 + 88 + // Resolve tasks from URIs 89 + if len(list.TaskURIs) > 0 { 90 + tasks, err := h.resolveTasksFromURIs(r.Context(), sess, list.TaskURIs) 91 + if err != nil { 92 + log.Printf("Failed to resolve tasks for list %s: %v", rkey, err) 93 + // Continue anyway, just with empty tasks 94 + } else { 95 + list.Tasks = tasks 96 + } 97 + } 98 + 99 + // Update session 100 + cookie, _ := r.Cookie("session_id") 101 + if cookie != nil { 102 + h.client.UpdateSession(cookie.Value, sess) 103 + } 104 + 105 + // Render list detail view 106 + w.Header().Set("Content-Type", "text/html") 107 + Render(w, "list-detail.html", list) 108 + } 109 + 110 + // handleCreateList creates a new list 111 + func (h *ListHandler) handleCreateList(w http.ResponseWriter, r *http.Request) { 112 + sess, ok := session.GetSession(r) 113 + if !ok { 114 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 115 + return 116 + } 117 + 118 + // Parse form data 119 + if err := r.ParseForm(); err != nil { 120 + http.Error(w, "Invalid form data", http.StatusBadRequest) 121 + return 122 + } 123 + 124 + name := r.FormValue("name") 125 + if name == "" { 126 + http.Error(w, "Name is required", http.StatusBadRequest) 127 + return 128 + } 129 + 130 + // Create new list 131 + list := &models.TaskList{ 132 + Name: name, 133 + Description: r.FormValue("description"), 134 + TaskURIs: []string{}, // Empty initially 135 + CreatedAt: time.Now(), 136 + UpdatedAt: time.Now(), 137 + } 138 + 139 + // Build record 140 + record := buildListRecord(list) 141 + 142 + // Create the record with retry logic 143 + var output *atproto.RepoCreateRecord_Output 144 + var err error 145 + sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 146 + output, err = h.client.CreateRecord(r.Context(), s, ListCollection, record) 147 + return err 148 + }) 149 + 150 + if err != nil { 151 + log.Printf("Failed to create list after retries: %v", err) 152 + http.Error(w, "Failed to create list", http.StatusInternalServerError) 153 + return 154 + } 155 + 156 + // Update session 157 + cookie, _ := r.Cookie("session_id") 158 + if cookie != nil { 159 + h.client.UpdateSession(cookie.Value, sess) 160 + } 161 + 162 + // Extract RKey from URI 163 + list.RKey = extractRKey(output.Uri) 164 + list.URI = output.Uri 165 + 166 + log.Printf("List created: %s (%s)", list.Name, list.RKey) 167 + 168 + // Return the list partial for HTMX 169 + w.Header().Set("Content-Type", "text/html") 170 + Render(w, "list-item.html", list) 171 + } 172 + 173 + // handleListLists retrieves all lists 174 + func (h *ListHandler) handleListLists(w http.ResponseWriter, r *http.Request) { 175 + sess, ok := session.GetSession(r) 176 + if !ok { 177 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 178 + return 179 + } 180 + 181 + // Get lists from repository 182 + var lists []*models.TaskList 183 + var err error 184 + sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 185 + lists, err = h.ListRecords(r.Context(), s) 186 + return err 187 + }) 188 + 189 + if err != nil { 190 + log.Printf("Failed to list lists: %v", err) 191 + http.Error(w, "Failed to list lists", http.StatusInternalServerError) 192 + return 193 + } 194 + 195 + // Update session 196 + cookie, _ := r.Cookie("session_id") 197 + if cookie != nil { 198 + h.client.UpdateSession(cookie.Value, sess) 199 + } 200 + 201 + // Return HTML partials for HTMX 202 + w.Header().Set("Content-Type", "text/html") 203 + for _, list := range lists { 204 + Render(w, "list-item.html", list) 205 + } 206 + } 207 + 208 + // handleUpdateList updates an existing list 209 + func (h *ListHandler) handleUpdateList(w http.ResponseWriter, r *http.Request) { 210 + sess, ok := session.GetSession(r) 211 + if !ok { 212 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 213 + return 214 + } 215 + 216 + // Parse form data 217 + if err := r.ParseForm(); err != nil { 218 + http.Error(w, "Invalid form data", http.StatusBadRequest) 219 + return 220 + } 221 + 222 + rkey := r.FormValue("rkey") 223 + if rkey == "" { 224 + http.Error(w, "rkey is required", http.StatusBadRequest) 225 + return 226 + } 227 + 228 + // Get current list 229 + var record map[string]interface{} 230 + var err error 231 + sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 232 + record, err = h.getRecord(r.Context(), s, rkey) 233 + return err 234 + }) 235 + 236 + if err != nil { 237 + log.Printf("Failed to get list: %v", err) 238 + http.Error(w, "Failed to get list", http.StatusInternalServerError) 239 + return 240 + } 241 + 242 + // Parse existing record 243 + list := parseListRecord(record) 244 + list.RKey = rkey 245 + // Build URI from DID and collection 246 + list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey) 247 + 248 + // Update fields 249 + if name := r.FormValue("name"); name != "" { 250 + list.Name = name 251 + } 252 + list.Description = r.FormValue("description") 253 + list.UpdatedAt = time.Now() 254 + 255 + // Handle task URI updates if provided 256 + if taskURIsJSON := r.FormValue("taskUris"); taskURIsJSON != "" { 257 + var taskURIs []string 258 + if err := json.Unmarshal([]byte(taskURIsJSON), &taskURIs); err == nil { 259 + list.TaskURIs = taskURIs 260 + } 261 + } 262 + 263 + // Build record and update 264 + updatedRecord := buildListRecord(list) 265 + sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 266 + return h.updateRecord(r.Context(), s, rkey, updatedRecord) 267 + }) 268 + 269 + if err != nil { 270 + log.Printf("Failed to update list: %v", err) 271 + http.Error(w, "Failed to update list", http.StatusInternalServerError) 272 + return 273 + } 274 + 275 + // Update session 276 + cookie, _ := r.Cookie("session_id") 277 + if cookie != nil { 278 + h.client.UpdateSession(cookie.Value, sess) 279 + } 280 + 281 + log.Printf("List updated: %s", rkey) 282 + 283 + // Return updated list partial 284 + w.Header().Set("Content-Type", "text/html") 285 + Render(w, "list-item.html", list) 286 + } 287 + 288 + // handleManageTasks adds or removes tasks from a list 289 + func (h *ListHandler) handleManageTasks(w http.ResponseWriter, r *http.Request) { 290 + sess, ok := session.GetSession(r) 291 + if !ok { 292 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 293 + return 294 + } 295 + 296 + // Parse form data 297 + if err := r.ParseForm(); err != nil { 298 + http.Error(w, "Invalid form data", http.StatusBadRequest) 299 + return 300 + } 301 + 302 + rkey := r.FormValue("rkey") 303 + taskURI := r.FormValue("taskUri") 304 + action := r.FormValue("action") // "add" or "remove" 305 + 306 + if rkey == "" || taskURI == "" || action == "" { 307 + http.Error(w, "rkey, taskUri, and action are required", http.StatusBadRequest) 308 + return 309 + } 310 + 311 + // Get current list 312 + var record map[string]interface{} 313 + var err error 314 + sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 315 + record, err = h.getRecord(r.Context(), s, rkey) 316 + return err 317 + }) 318 + 319 + if err != nil { 320 + log.Printf("Failed to get list: %v", err) 321 + http.Error(w, "Failed to get list", http.StatusInternalServerError) 322 + return 323 + } 324 + 325 + // Parse list 326 + list := parseListRecord(record) 327 + list.RKey = rkey 328 + list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey) 329 + 330 + // Modify task URIs based on action 331 + switch action { 332 + case "add": 333 + // Check if task is already in the list 334 + found := false 335 + for _, uri := range list.TaskURIs { 336 + if uri == taskURI { 337 + found = true 338 + break 339 + } 340 + } 341 + if !found { 342 + list.TaskURIs = append(list.TaskURIs, taskURI) 343 + } 344 + case "remove": 345 + // Remove task from list 346 + newURIs := make([]string, 0, len(list.TaskURIs)) 347 + for _, uri := range list.TaskURIs { 348 + if uri != taskURI { 349 + newURIs = append(newURIs, uri) 350 + } 351 + } 352 + list.TaskURIs = newURIs 353 + default: 354 + http.Error(w, "Invalid action", http.StatusBadRequest) 355 + return 356 + } 357 + 358 + // Update timestamp 359 + list.UpdatedAt = time.Now() 360 + 361 + // Build record and update 362 + updatedRecord := buildListRecord(list) 363 + sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 364 + return h.updateRecord(r.Context(), s, rkey, updatedRecord) 365 + }) 366 + 367 + if err != nil { 368 + log.Printf("Failed to update list tasks: %v", err) 369 + http.Error(w, "Failed to update list", http.StatusInternalServerError) 370 + return 371 + } 372 + 373 + // Update session 374 + cookie, _ := r.Cookie("session_id") 375 + if cookie != nil { 376 + h.client.UpdateSession(cookie.Value, sess) 377 + } 378 + 379 + log.Printf("Task %s %sd to/from list %s", taskURI, action, rkey) 380 + 381 + // Return success 382 + w.WriteHeader(http.StatusOK) 383 + w.Write([]byte("Success")) 384 + } 385 + 386 + // handleDeleteList deletes a list 387 + func (h *ListHandler) handleDeleteList(w http.ResponseWriter, r *http.Request) { 388 + sess, ok := session.GetSession(r) 389 + if !ok { 390 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 391 + return 392 + } 393 + 394 + rkey := r.URL.Query().Get("rkey") 395 + if rkey == "" { 396 + http.Error(w, "rkey is required", http.StatusBadRequest) 397 + return 398 + } 399 + 400 + // Delete with retry logic 401 + var err error 402 + sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 403 + return h.client.DeleteRecord(r.Context(), s, ListCollection, rkey) 404 + }) 405 + 406 + if err != nil { 407 + log.Printf("Failed to delete list after retries: %v", err) 408 + http.Error(w, "Failed to delete list", http.StatusInternalServerError) 409 + return 410 + } 411 + 412 + // Update session 413 + cookie, _ := r.Cookie("session_id") 414 + if cookie != nil { 415 + h.client.UpdateSession(cookie.Value, sess) 416 + } 417 + 418 + log.Printf("List deleted: %s", rkey) 419 + w.WriteHeader(http.StatusOK) 420 + } 421 + 422 + // listRecords fetches all lists from the repository 423 + // ListRecords fetches all list records for the given session (public for cross-handler access) 424 + func (h *ListHandler) ListRecords(ctx context.Context, sess *bskyoauth.Session) ([]*models.TaskList, error) { 425 + // Build the XRPC URL 426 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", 427 + sess.PDS, sess.DID, ListCollection) 428 + 429 + // Create request 430 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 431 + if err != nil { 432 + return nil, err 433 + } 434 + 435 + // Add authorization header 436 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 437 + 438 + // Make request 439 + client := &http.Client{Timeout: 10 * time.Second} 440 + resp, err := client.Do(req) 441 + if err != nil { 442 + return nil, err 443 + } 444 + defer resp.Body.Close() 445 + 446 + if resp.StatusCode != http.StatusOK { 447 + body, _ := io.ReadAll(resp.Body) 448 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body)) 449 + } 450 + 451 + // Parse response 452 + var result struct { 453 + Records []struct { 454 + URI string `json:"uri"` 455 + Value map[string]interface{} `json:"value"` 456 + } `json:"records"` 457 + } 458 + 459 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 460 + return nil, err 461 + } 462 + 463 + // Convert records to TaskList objects 464 + lists := make([]*models.TaskList, 0, len(result.Records)) 465 + for _, record := range result.Records { 466 + list := parseListRecord(record.Value) 467 + list.URI = record.URI 468 + list.RKey = extractRKey(record.URI) 469 + lists = append(lists, list) 470 + } 471 + 472 + return lists, nil 473 + } 474 + 475 + // getRecord retrieves a single record using com.atproto.repo.getRecord 476 + func (h *ListHandler) getRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) { 477 + // Build the XRPC URL 478 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 479 + sess.PDS, sess.DID, ListCollection, rkey) 480 + 481 + // Create request 482 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 483 + if err != nil { 484 + return nil, err 485 + } 486 + 487 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 488 + 489 + // Create HTTP client with DPoP transport 490 + transport := bskyoauth.NewDPoPTransport(http.DefaultTransport, sess.DPoPKey, sess.AccessToken, sess.DPoPNonce) 491 + client := &http.Client{ 492 + Transport: transport, 493 + Timeout: 10 * time.Second, 494 + } 495 + 496 + resp, err := client.Do(req) 497 + if err != nil { 498 + return nil, err 499 + } 500 + defer resp.Body.Close() 501 + 502 + // Update nonce if present 503 + if dpopTransport, ok := transport.(bskyoauth.DPoPTransport); ok { 504 + sess.DPoPNonce = dpopTransport.GetNonce() 505 + } 506 + 507 + if resp.StatusCode != http.StatusOK { 508 + bodyBytes, _ := io.ReadAll(resp.Body) 509 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes)) 510 + } 511 + 512 + // Parse response 513 + var result struct { 514 + URI string `json:"uri"` 515 + CID string `json:"cid"` 516 + Value map[string]interface{} `json:"value"` 517 + } 518 + 519 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 520 + return nil, fmt.Errorf("failed to decode response: %w", err) 521 + } 522 + 523 + return result.Value, nil 524 + } 525 + 526 + // updateRecord updates a record using com.atproto.repo.putRecord 527 + func (h *ListHandler) updateRecord(ctx context.Context, sess *bskyoauth.Session, rkey string, record map[string]interface{}) error { 528 + log.Printf("updateRecord: DID=%s, Collection=%s, RKey=%s", sess.DID, ListCollection, rkey) 529 + 530 + // Resolve the actual PDS endpoint for this user 531 + pdsHost, err := h.resolvePDSEndpoint(ctx, sess.DID) 532 + if err != nil { 533 + return fmt.Errorf("failed to resolve PDS endpoint: %w", err) 534 + } 535 + log.Printf("updateRecord: Resolved PDS=%s", pdsHost) 536 + 537 + // Add $type field to the record if not present 538 + if _, exists := record["$type"]; !exists { 539 + record["$type"] = ListCollection 540 + } 541 + 542 + // Build the request body 543 + body := map[string]interface{}{ 544 + "repo": sess.DID, 545 + "collection": ListCollection, 546 + "rkey": rkey, 547 + "record": record, 548 + } 549 + 550 + bodyJSON, err := json.Marshal(body) 551 + if err != nil { 552 + return fmt.Errorf("failed to marshal request: %w", err) 553 + } 554 + 555 + // Create the request to the resolved PDS endpoint 556 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", pdsHost) 557 + req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(bodyJSON))) 558 + if err != nil { 559 + return err 560 + } 561 + 562 + req.Header.Set("Content-Type", "application/json") 563 + 564 + // Create DPoP transport for authentication 565 + dpopTransport := bskyoauth.NewDPoPTransport( 566 + http.DefaultTransport, 567 + sess.DPoPKey, 568 + sess.AccessToken, 569 + sess.DPoPNonce, 570 + ) 571 + 572 + httpClient := &http.Client{ 573 + Transport: dpopTransport, 574 + Timeout: 10 * time.Second, 575 + } 576 + 577 + resp, err := httpClient.Do(req) 578 + if err != nil { 579 + return err 580 + } 581 + defer resp.Body.Close() 582 + 583 + if resp.StatusCode != http.StatusOK { 584 + bodyBytes, _ := io.ReadAll(resp.Body) 585 + log.Printf("updateRecord: HTTP %d: %s", resp.StatusCode, string(bodyBytes)) 586 + return fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes)) 587 + } 588 + 589 + var output atproto.RepoPutRecord_Output 590 + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { 591 + return fmt.Errorf("failed to decode response: %w", err) 592 + } 593 + 594 + log.Printf("updateRecord: Success! URI=%s", output.Uri) 595 + return nil 596 + } 597 + 598 + // resolvePDSEndpoint resolves the PDS endpoint for a given DID 599 + func (h *ListHandler) resolvePDSEndpoint(ctx context.Context, did string) (string, error) { 600 + dir := identity.DefaultDirectory() 601 + atid, err := syntax.ParseAtIdentifier(did) 602 + if err != nil { 603 + return "", err 604 + } 605 + 606 + ident, err := dir.Lookup(ctx, *atid) 607 + if err != nil { 608 + return "", err 609 + } 610 + 611 + return ident.PDSEndpoint(), nil 612 + } 613 + 614 + // resolveTasksFromURIs fetches task records from their AT URIs 615 + func (h *ListHandler) resolveTasksFromURIs(ctx context.Context, sess *bskyoauth.Session, taskURIs []string) ([]*models.Task, error) { 616 + tasks := make([]*models.Task, 0, len(taskURIs)) 617 + 618 + for _, uri := range taskURIs { 619 + // Parse the URI to extract collection and rkey 620 + // Format: at://did:plc:xxx/app.attodo.task/rkey 621 + parts := strings.Split(uri, "/") 622 + if len(parts) < 4 { 623 + log.Printf("Invalid task URI format: %s", uri) 624 + continue 625 + } 626 + 627 + collection := parts[len(parts)-2] 628 + rkey := parts[len(parts)-1] 629 + 630 + // Only fetch if it's a task collection 631 + if collection != "app.attodo.task" { 632 + log.Printf("Skipping non-task URI: %s", uri) 633 + continue 634 + } 635 + 636 + // Fetch the task record 637 + taskRecord, err := h.getTaskRecord(ctx, sess, rkey) 638 + if err != nil { 639 + log.Printf("Failed to fetch task %s: %v", rkey, err) 640 + continue 641 + } 642 + 643 + // Parse task 644 + task := parseTaskRecord(taskRecord) 645 + task.RKey = rkey 646 + task.URI = uri 647 + 648 + tasks = append(tasks, task) 649 + } 650 + 651 + return tasks, nil 652 + } 653 + 654 + // getTaskRecord retrieves a single task record using com.atproto.repo.getRecord 655 + func (h *ListHandler) getTaskRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) { 656 + // Build the XRPC URL for tasks 657 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 658 + sess.PDS, sess.DID, "app.attodo.task", rkey) 659 + 660 + // Create request 661 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 662 + if err != nil { 663 + return nil, err 664 + } 665 + 666 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 667 + 668 + // Create HTTP client with DPoP transport 669 + transport := bskyoauth.NewDPoPTransport(http.DefaultTransport, sess.DPoPKey, sess.AccessToken, sess.DPoPNonce) 670 + client := &http.Client{ 671 + Transport: transport, 672 + Timeout: 10 * time.Second, 673 + } 674 + 675 + resp, err := client.Do(req) 676 + if err != nil { 677 + return nil, err 678 + } 679 + defer resp.Body.Close() 680 + 681 + // Update nonce if present 682 + if dpopTransport, ok := transport.(bskyoauth.DPoPTransport); ok { 683 + sess.DPoPNonce = dpopTransport.GetNonce() 684 + } 685 + 686 + if resp.StatusCode != http.StatusOK { 687 + bodyBytes, _ := io.ReadAll(resp.Body) 688 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes)) 689 + } 690 + 691 + // Parse response 692 + var result struct { 693 + URI string `json:"uri"` 694 + CID string `json:"cid"` 695 + Value map[string]interface{} `json:"value"` 696 + } 697 + 698 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 699 + return nil, fmt.Errorf("failed to decode response: %w", err) 700 + } 701 + 702 + return result.Value, nil 703 + } 704 + 705 + // parseTaskRecord parses a task record from AT Protocol 706 + func parseTaskRecord(record map[string]interface{}) *models.Task { 707 + task := &models.Task{} 708 + 709 + if title, ok := record["title"].(string); ok { 710 + task.Title = title 711 + } 712 + if desc, ok := record["description"].(string); ok { 713 + task.Description = desc 714 + } 715 + if completed, ok := record["completed"].(bool); ok { 716 + task.Completed = completed 717 + } 718 + if createdAt, ok := record["createdAt"].(string); ok { 719 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 720 + task.CreatedAt = t 721 + } 722 + } 723 + if completedAt, ok := record["completedAt"].(string); ok { 724 + if t, err := time.Parse(time.RFC3339, completedAt); err == nil { 725 + task.CompletedAt = &t 726 + } 727 + } 728 + 729 + return task 730 + } 731 + 732 + // withRetry handles token refresh and retries 733 + // WithRetry executes an operation with automatic token refresh on errors (public for cross-handler access) 734 + func (h *ListHandler) WithRetry(ctx context.Context, sess *bskyoauth.Session, fn func(*bskyoauth.Session) error) (*bskyoauth.Session, error) { 735 + const maxRetries = 3 736 + 737 + for i := 0; i < maxRetries; i++ { 738 + err := fn(sess) 739 + if err == nil { 740 + return sess, nil 741 + } 742 + 743 + // Check if it's a token expiration error 744 + if strings.Contains(err.Error(), "400") || strings.Contains(err.Error(), "401") { 745 + log.Printf("Token may be expired, attempting refresh (attempt %d/%d)", i+1, maxRetries) 746 + 747 + // Try to refresh the token 748 + newSess, refreshErr := h.client.RefreshToken(ctx, sess) 749 + if refreshErr != nil { 750 + log.Printf("Failed to refresh token: %v", refreshErr) 751 + return sess, err // Return original error 752 + } 753 + 754 + sess = newSess 755 + continue 756 + } 757 + 758 + // Not a token error, return immediately 759 + return sess, err 760 + } 761 + 762 + return sess, fmt.Errorf("max retries exceeded") 763 + } 764 + 765 + // Helper functions for record building/parsing 766 + 767 + func buildListRecord(list *models.TaskList) map[string]interface{} { 768 + return map[string]interface{}{ 769 + "$type": ListCollection, 770 + "name": list.Name, 771 + "description": list.Description, 772 + "taskUris": list.TaskURIs, 773 + "createdAt": list.CreatedAt.Format(time.RFC3339), 774 + "updatedAt": list.UpdatedAt.Format(time.RFC3339), 775 + } 776 + } 777 + 778 + func parseListRecord(value map[string]interface{}) *models.TaskList { 779 + list := &models.TaskList{} 780 + 781 + if name, ok := value["name"].(string); ok { 782 + list.Name = name 783 + } 784 + if desc, ok := value["description"].(string); ok { 785 + list.Description = desc 786 + } 787 + if taskURIs, ok := value["taskUris"].([]interface{}); ok { 788 + list.TaskURIs = make([]string, 0, len(taskURIs)) 789 + for _, uri := range taskURIs { 790 + if uriStr, ok := uri.(string); ok { 791 + list.TaskURIs = append(list.TaskURIs, uriStr) 792 + } 793 + } 794 + } 795 + if createdAt, ok := value["createdAt"].(string); ok { 796 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 797 + list.CreatedAt = t 798 + } 799 + } 800 + if updatedAt, ok := value["updatedAt"].(string); ok { 801 + if t, err := time.Parse(time.RFC3339, updatedAt); err == nil { 802 + list.UpdatedAt = t 803 + } 804 + } 805 + 806 + return list 807 + }
+34 -1
internal/handlers/tasks.go
··· 21 const TaskCollection = "app.attodo.task" 22 23 type TaskHandler struct { 24 - client *bskyoauth.Client 25 } 26 27 func NewTaskHandler(client *bskyoauth.Client) *TaskHandler { 28 return &TaskHandler{client: client} 29 } 30 31 // withRetry executes an operation with automatic token refresh on DPoP errors ··· 395 log.Printf("Failed to list tasks: %v", err) 396 // Return empty list on error rather than failing 397 tasks = []models.Task{} 398 } 399 400 // Filter tasks based on completion status
··· 21 const TaskCollection = "app.attodo.task" 22 23 type TaskHandler struct { 24 + client *bskyoauth.Client 25 + listHandler *ListHandler 26 } 27 28 func NewTaskHandler(client *bskyoauth.Client) *TaskHandler { 29 return &TaskHandler{client: client} 30 + } 31 + 32 + // SetListHandler allows setting the list handler for cross-referencing 33 + func (h *TaskHandler) SetListHandler(listHandler *ListHandler) { 34 + h.listHandler = listHandler 35 } 36 37 // withRetry executes an operation with automatic token refresh on DPoP errors ··· 401 log.Printf("Failed to list tasks: %v", err) 402 // Return empty list on error rather than failing 403 tasks = []models.Task{} 404 + } 405 + 406 + // Fetch all lists to populate task-to-list relationships 407 + if h.listHandler != nil { 408 + var lists []*models.TaskList 409 + sess, err = h.listHandler.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 410 + lists, err = h.listHandler.ListRecords(r.Context(), s) 411 + return err 412 + }) 413 + 414 + if err == nil && lists != nil { 415 + // Create a map of task URI to lists 416 + taskListMap := make(map[string][]*models.TaskList) 417 + for _, list := range lists { 418 + for _, taskURI := range list.TaskURIs { 419 + taskListMap[taskURI] = append(taskListMap[taskURI], list) 420 + } 421 + } 422 + 423 + // Populate the Lists field for each task 424 + for i := range tasks { 425 + taskURI := tasks[i].URI 426 + if taskLists, exists := taskListMap[taskURI]; exists { 427 + tasks[i].Lists = taskLists 428 + } 429 + } 430 + } 431 } 432 433 // Filter tasks based on completion status
+19
internal/models/task.go
··· 13 // Metadata from AT Protocol (populated after creation) 14 RKey string `json:"-"` // Record key (extracted from URI) 15 URI string `json:"-"` // Full AT URI 16 }
··· 13 // Metadata from AT Protocol (populated after creation) 14 RKey string `json:"-"` // Record key (extracted from URI) 15 URI string `json:"-"` // Full AT URI 16 + 17 + // Transient field - populated when fetching task with list memberships 18 + Lists []*TaskList `json:"-"` // Lists this task belongs to (not stored in AT Protocol) 19 + } 20 + 21 + // TaskList represents a collection of tasks stored in AT Protocol 22 + type TaskList struct { 23 + Name string `json:"name"` // Name of the list (e.g., "Work", "Personal", "Shopping") 24 + Description string `json:"description,omitempty"` // Optional description of the list 25 + TaskURIs []string `json:"taskUris"` // Array of AT URIs referencing tasks 26 + CreatedAt time.Time `json:"createdAt"` 27 + UpdatedAt time.Time `json:"updatedAt"` 28 + 29 + // Metadata from AT Protocol (populated after creation) 30 + RKey string `json:"-"` // Record key (extracted from URI) 31 + URI string `json:"-"` // Full AT URI 32 + 33 + // Transient field - populated when fetching list with tasks 34 + Tasks []*Task `json:"-"` // Resolved task objects (not stored in AT Protocol) 35 }
+403
templates/dashboard.html
··· 76 .tab-content.active { 77 display: block; 78 } 79 </style> 80 <script> 81 function switchTab(tabName) { 82 // Remove active class from all tabs and tab contents 83 document.querySelectorAll('.tabs button').forEach(btn => { ··· 108 taskItem.querySelector('.task-edit').style.display = 'none'; 109 taskItem.querySelector('.task-actions').style.display = 'flex'; 110 } 111 </script> 112 </head> 113 <body> 114 <script> 115 // Register service worker 116 if ('serviceWorker' in navigator) { ··· 129 } 130 }); 131 } 132 </script> 133 <header class="container"> 134 <nav> ··· 176 <div class="tabs"> 177 <button class="active" onclick="switchTab('incomplete')">Incomplete</button> 178 <button onclick="switchTab('completed')">Completed</button> 179 </div> 180 181 <!-- Incomplete Tasks Tab --> ··· 191 <!-- Completed tasks will be loaded here --> 192 </div> 193 </div> 194 </section> 195 </main> 196 ··· 202 {{getVersion}}-{{getCommitID}} 203 </p> 204 </footer> 205 </body> 206 </html> 207 {{end}}
··· 76 .tab-content.active { 77 display: block; 78 } 79 + .list-item { 80 + padding: 1rem; 81 + margin: 0.5rem 0; 82 + border: 1px solid var(--pico-muted-border-color); 83 + border-radius: var(--pico-border-radius); 84 + } 85 + .list-item h4 { 86 + margin: 0 0 0.5rem 0; 87 + } 88 + .list-item.htmx-swapping, 89 + .list-item.list-removing { 90 + display: none; 91 + } 92 + .list-actions { 93 + display: flex; 94 + gap: 0.5rem; 95 + margin-top: 0.5rem; 96 + justify-content: flex-end; 97 + } 98 + .list-actions button { 99 + padding: 0.25rem 0.75rem; 100 + margin: 0; 101 + } 102 + /* Modal styles */ 103 + .modal { 104 + display: none; 105 + position: fixed; 106 + z-index: 1000; 107 + left: 0; 108 + top: 0; 109 + width: 100%; 110 + height: 100%; 111 + background-color: rgba(0, 0, 0, 0.5); 112 + } 113 + .modal.active { 114 + display: flex; 115 + align-items: center; 116 + justify-content: center; 117 + } 118 + .modal-content { 119 + background-color: var(--pico-background-color); 120 + padding: 2rem; 121 + border-radius: var(--pico-border-radius); 122 + max-width: 500px; 123 + width: 90%; 124 + max-height: 80vh; 125 + overflow-y: auto; 126 + } 127 + .modal-content h3 { 128 + margin-top: 0; 129 + } 130 + .list-selector { 131 + margin: 1rem 0; 132 + } 133 + .list-selector-item { 134 + padding: 0.75rem; 135 + margin: 0.5rem 0; 136 + border: 1px solid var(--pico-muted-border-color); 137 + border-radius: var(--pico-border-radius); 138 + cursor: pointer; 139 + transition: background-color 0.2s; 140 + } 141 + .list-selector-item:hover { 142 + background-color: var(--pico-primary-hover-background); 143 + } 144 + .list-selector-item h5 { 145 + margin: 0 0 0.25rem 0; 146 + } 147 + .list-selector-item p { 148 + margin: 0; 149 + font-size: 0.875rem; 150 + color: var(--pico-muted-color); 151 + } 152 + /* Toast notification styles */ 153 + .toast-container { 154 + position: fixed; 155 + top: 20px; 156 + right: 20px; 157 + z-index: 2000; 158 + display: flex; 159 + flex-direction: column; 160 + gap: 0.5rem; 161 + } 162 + .toast { 163 + background-color: var(--pico-background-color); 164 + border: 2px solid var(--pico-primary); 165 + border-radius: var(--pico-border-radius); 166 + padding: 1rem 1.5rem; 167 + min-width: 250px; 168 + max-width: 400px; 169 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 170 + animation: slideIn 0.3s ease-out; 171 + display: flex; 172 + align-items: center; 173 + justify-content: space-between; 174 + gap: 1rem; 175 + } 176 + .toast.success { 177 + border-color: #10b981; 178 + } 179 + .toast.error { 180 + border-color: #ef4444; 181 + } 182 + .toast.info { 183 + border-color: #3b82f6; 184 + } 185 + .toast-message { 186 + flex: 1; 187 + } 188 + .toast-close { 189 + background: none; 190 + border: none; 191 + cursor: pointer; 192 + padding: 0; 193 + margin: 0; 194 + font-size: 1.25rem; 195 + line-height: 1; 196 + color: var(--pico-muted-color); 197 + opacity: 0.7; 198 + } 199 + .toast-close:hover { 200 + opacity: 1; 201 + } 202 + @keyframes slideIn { 203 + from { 204 + transform: translateX(400px); 205 + opacity: 0; 206 + } 207 + to { 208 + transform: translateX(0); 209 + opacity: 1; 210 + } 211 + } 212 + @keyframes slideOut { 213 + from { 214 + transform: translateX(0); 215 + opacity: 1; 216 + } 217 + to { 218 + transform: translateX(400px); 219 + opacity: 0; 220 + } 221 + } 222 + .toast.closing { 223 + animation: slideOut 0.3s ease-in forwards; 224 + } 225 + /* Loading animation for tasks being added to lists */ 226 + .task-item.adding-to-list { 227 + position: relative; 228 + pointer-events: none; 229 + opacity: 0.6; 230 + } 231 + .task-item.adding-to-list::after { 232 + content: ''; 233 + position: absolute; 234 + top: 50%; 235 + left: 50%; 236 + width: 24px; 237 + height: 24px; 238 + margin: -12px 0 0 -12px; 239 + border: 3px solid var(--pico-primary); 240 + border-radius: 50%; 241 + border-top-color: transparent; 242 + animation: spin 0.8s linear infinite; 243 + } 244 + @keyframes spin { 245 + to { transform: rotate(360deg); } 246 + } 247 </style> 248 <script> 249 + // Toast notification system 250 + function showToast(message, type = 'info', duration = 3000) { 251 + const container = document.getElementById('toast-container'); 252 + 253 + const toast = document.createElement('div'); 254 + toast.className = `toast ${type}`; 255 + 256 + const messageDiv = document.createElement('div'); 257 + messageDiv.className = 'toast-message'; 258 + messageDiv.textContent = message; 259 + 260 + const closeBtn = document.createElement('button'); 261 + closeBtn.className = 'toast-close'; 262 + closeBtn.innerHTML = '&times;'; 263 + closeBtn.setAttribute('aria-label', 'Close'); 264 + closeBtn.onclick = () => closeToast(toast); 265 + 266 + toast.appendChild(messageDiv); 267 + toast.appendChild(closeBtn); 268 + container.appendChild(toast); 269 + 270 + // Auto-dismiss after duration 271 + setTimeout(() => closeToast(toast), duration); 272 + } 273 + 274 + function closeToast(toast) { 275 + toast.classList.add('closing'); 276 + setTimeout(() => { 277 + if (toast.parentNode) { 278 + toast.parentNode.removeChild(toast); 279 + } 280 + }, 300); // Match animation duration 281 + } 282 + 283 function switchTab(tabName) { 284 // Remove active class from all tabs and tab contents 285 document.querySelectorAll('.tabs button').forEach(btn => { ··· 310 taskItem.querySelector('.task-edit').style.display = 'none'; 311 taskItem.querySelector('.task-actions').style.display = 'flex'; 312 } 313 + 314 + function startListEdit(rkey) { 315 + const listItem = document.getElementById('list-' + rkey); 316 + listItem.querySelector('.list-view').style.display = 'none'; 317 + listItem.querySelector('.list-edit').style.display = 'block'; 318 + listItem.querySelector('.list-actions').style.display = 'none'; 319 + } 320 + 321 + function cancelListEdit(rkey) { 322 + const listItem = document.getElementById('list-' + rkey); 323 + listItem.querySelector('.list-view').style.display = 'block'; 324 + listItem.querySelector('.list-edit').style.display = 'none'; 325 + listItem.querySelector('.list-actions').style.display = 'flex'; 326 + } 327 + 328 + async function showAddToList(taskRKey, taskURI) { 329 + // Fetch available lists 330 + try { 331 + const response = await fetch('/app/lists'); 332 + const html = await response.text(); 333 + 334 + // Parse the HTML to extract list data 335 + const parser = new DOMParser(); 336 + const doc = parser.parseFromString(html, 'text/html'); 337 + const listItems = doc.querySelectorAll('.list-item'); 338 + 339 + if (listItems.length === 0) { 340 + showToast('No lists available. Please create a list first in the Lists tab.', 'info', 4000); 341 + return; 342 + } 343 + 344 + // Build modal content 345 + let modalHTML = '<div class="list-selector">'; 346 + listItems.forEach(item => { 347 + const rkey = item.id.replace('list-', ''); 348 + const name = item.querySelector('h4').textContent; 349 + const description = item.querySelector('p')?.textContent || ''; 350 + 351 + modalHTML += ` 352 + <div class="list-selector-item" onclick="addTaskToList('${rkey}', '${taskURI}')"> 353 + <h5>${name}</h5> 354 + ${description ? `<p>${description}</p>` : ''} 355 + </div> 356 + `; 357 + }); 358 + modalHTML += '</div>'; 359 + 360 + // Show modal 361 + const modal = document.getElementById('list-selector-modal'); 362 + const modalBody = document.getElementById('list-selector-body'); 363 + modalBody.innerHTML = modalHTML; 364 + modal.classList.add('active'); 365 + 366 + } catch (error) { 367 + console.error('Failed to fetch lists:', error); 368 + showToast('Failed to load lists. Please try again.', 'error'); 369 + } 370 + } 371 + 372 + function addTaskToList(listRKey, taskURI) { 373 + // Close modal 374 + document.getElementById('list-selector-modal').classList.remove('active'); 375 + 376 + // Extract task rkey from URI (e.g., at://did:plc:xxx/app.attodo.task/rkey) 377 + const taskRKey = taskURI.split('/').pop(); 378 + 379 + // Show loading animation on the task 380 + const taskElement = document.getElementById('task-' + taskRKey); 381 + if (taskElement) { 382 + taskElement.classList.add('adding-to-list'); 383 + } 384 + 385 + // Add task to list using fetch to avoid HTMX content swapping 386 + const formData = new URLSearchParams(); 387 + formData.append('rkey', listRKey); 388 + formData.append('taskUri', taskURI); 389 + formData.append('action', 'add'); 390 + 391 + fetch('/app/lists', { 392 + method: 'PATCH', 393 + headers: { 394 + 'Content-Type': 'application/x-www-form-urlencoded' 395 + }, 396 + body: formData 397 + }) 398 + .then(response => { 399 + if (response.ok) { 400 + showToast('Task added to list successfully!', 'success'); 401 + 402 + // Reload the specific task item to show updated list membership 403 + if (taskElement) { 404 + htmx.ajax('GET', '/app/tasks?filter=' + (taskElement.classList.contains('completed') ? 'completed' : 'incomplete'), { 405 + target: '#task-' + taskRKey, 406 + swap: 'outerHTML', 407 + select: '#task-' + taskRKey 408 + }); 409 + } 410 + } else { 411 + showToast('Failed to add task to list. Please try again.', 'error'); 412 + // Remove loading animation on error 413 + if (taskElement) { 414 + taskElement.classList.remove('adding-to-list'); 415 + } 416 + } 417 + }) 418 + .catch(() => { 419 + showToast('Failed to add task to list. Please try again.', 'error'); 420 + // Remove loading animation on error 421 + if (taskElement) { 422 + taskElement.classList.remove('adding-to-list'); 423 + } 424 + }); 425 + } 426 + 427 + function closeModal() { 428 + document.getElementById('list-selector-modal').classList.remove('active'); 429 + } 430 </script> 431 </head> 432 <body> 433 + <!-- Toast notification container --> 434 + <div id="toast-container" class="toast-container"></div> 435 + 436 <script> 437 // Register service worker 438 if ('serviceWorker' in navigator) { ··· 451 } 452 }); 453 } 454 + 455 + // HTMX event listeners for operations with toast notifications 456 + document.body.addEventListener('htmx:afterRequest', function(evt) { 457 + const target = evt.detail.target; 458 + const verb = evt.detail.xhr?.status; 459 + const url = evt.detail.pathInfo?.requestPath; 460 + 461 + // Task operations 462 + if (url?.includes('/app/tasks')) { 463 + if (evt.detail.successful) { 464 + if (evt.detail.verb === 'post') { 465 + showToast('Task created successfully!', 'success'); 466 + } else if (evt.detail.verb === 'put') { 467 + showToast('Task updated successfully!', 'success'); 468 + } else if (evt.detail.verb === 'delete') { 469 + showToast('Task deleted successfully!', 'success'); 470 + } 471 + } else { 472 + showToast('Operation failed. Please try again.', 'error'); 473 + } 474 + } 475 + 476 + // List operations 477 + if (url?.includes('/app/lists')) { 478 + if (evt.detail.successful) { 479 + if (evt.detail.verb === 'post') { 480 + showToast('List created successfully!', 'success'); 481 + } else if (evt.detail.verb === 'put') { 482 + showToast('List updated successfully!', 'success'); 483 + } else if (evt.detail.verb === 'delete') { 484 + showToast('List deleted successfully!', 'success'); 485 + } 486 + } else if (!evt.detail.successful && evt.detail.verb !== 'get') { 487 + showToast('Operation failed. Please try again.', 'error'); 488 + } 489 + } 490 + }); 491 </script> 492 <header class="container"> 493 <nav> ··· 535 <div class="tabs"> 536 <button class="active" onclick="switchTab('incomplete')">Incomplete</button> 537 <button onclick="switchTab('completed')">Completed</button> 538 + <button onclick="switchTab('lists')">Lists</button> 539 </div> 540 541 <!-- Incomplete Tasks Tab --> ··· 551 <!-- Completed tasks will be loaded here --> 552 </div> 553 </div> 554 + 555 + <!-- Lists Tab --> 556 + <div id="lists-tab" class="tab-content"> 557 + <!-- Create List Form --> 558 + <article style="margin-bottom: 2rem;"> 559 + <h3>Create New List</h3> 560 + <form 561 + hx-post="/app/lists" 562 + hx-target="#lists-list" 563 + hx-swap="afterbegin" 564 + hx-on::after-request="this.reset()" 565 + > 566 + <label for="list-name"> 567 + List Name 568 + <input type="text" name="name" id="list-name" required placeholder="e.g., Work, Personal, Shopping"> 569 + </label> 570 + 571 + <label for="list-description"> 572 + Description (optional) 573 + <textarea name="description" id="list-description" rows="2" placeholder="Describe this list..."></textarea> 574 + </label> 575 + 576 + <button type="submit">Create List</button> 577 + </form> 578 + </article> 579 + 580 + <!-- Lists Display --> 581 + <div id="lists-list" hx-get="/app/lists" hx-trigger="load, reload from:body" hx-swap="innerHTML"> 582 + <!-- Lists will be loaded here --> 583 + </div> 584 + </div> 585 </section> 586 </main> 587 ··· 593 {{getVersion}}-{{getCommitID}} 594 </p> 595 </footer> 596 + 597 + <!-- List Selector Modal --> 598 + <div id="list-selector-modal" class="modal" onclick="if(event.target === this) closeModal()"> 599 + <div class="modal-content"> 600 + <h3>Add Task to List</h3> 601 + <p>Select a list to add this task to:</p> 602 + <div id="list-selector-body"> 603 + <!-- List items will be dynamically inserted here --> 604 + </div> 605 + <button onclick="closeModal()" class="secondary" style="width: 100%; margin-top: 1rem;">Cancel</button> 606 + </div> 607 + </div> 608 </body> 609 </html> 610 {{end}}
+269
templates/list-detail.html
···
··· 1 + {{define "list-detail.html"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>{{.Name}} - AT Todo</title> 8 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> 9 + <script src="https://unpkg.com/htmx.org@2.0.8"></script> 10 + <link rel="manifest" href="/static/manifest.json"> 11 + <link rel="icon" type="image/svg+xml" href="/static/icon.svg"> 12 + <meta name="theme-color" content="#1e88e5"> 13 + <style> 14 + .container { 15 + max-width: 800px; 16 + } 17 + .list-header { 18 + margin-bottom: 2rem; 19 + padding-bottom: 1rem; 20 + border-bottom: 1px solid var(--pico-muted-border-color); 21 + } 22 + .list-header h1 { 23 + margin-bottom: 0.5rem; 24 + } 25 + .list-meta { 26 + color: var(--pico-muted-color); 27 + font-size: 0.9rem; 28 + } 29 + .task-item { 30 + padding: 1rem; 31 + margin: 0.5rem 0; 32 + border: 1px solid var(--pico-muted-border-color); 33 + border-radius: var(--pico-border-radius); 34 + } 35 + .task-item.completed { 36 + opacity: 0.6; 37 + } 38 + .task-item h4 { 39 + margin: 0 0 0.5rem 0; 40 + } 41 + .task-item.completed h4 { 42 + text-decoration: line-through; 43 + } 44 + .task-actions { 45 + display: flex; 46 + gap: 0.5rem; 47 + margin-top: 0.5rem; 48 + justify-content: flex-end; 49 + } 50 + .task-actions button { 51 + padding: 0.25rem 0.75rem; 52 + margin: 0; 53 + } 54 + .empty-state { 55 + text-align: center; 56 + padding: 3rem 2rem; 57 + color: var(--pico-muted-color); 58 + } 59 + /* Toast notification styles */ 60 + .toast-container { 61 + position: fixed; 62 + top: 20px; 63 + right: 20px; 64 + z-index: 2000; 65 + display: flex; 66 + flex-direction: column; 67 + gap: 0.5rem; 68 + } 69 + .toast { 70 + background-color: var(--pico-background-color); 71 + border: 2px solid var(--pico-primary); 72 + border-radius: var(--pico-border-radius); 73 + padding: 1rem 1.5rem; 74 + min-width: 250px; 75 + max-width: 400px; 76 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 77 + animation: slideIn 0.3s ease-out; 78 + display: flex; 79 + align-items: center; 80 + justify-content: space-between; 81 + gap: 1rem; 82 + } 83 + .toast.success { 84 + border-color: #10b981; 85 + } 86 + .toast.error { 87 + border-color: #ef4444; 88 + } 89 + .toast.info { 90 + border-color: #3b82f6; 91 + } 92 + .toast-message { 93 + flex: 1; 94 + } 95 + .toast-close { 96 + background: none; 97 + border: none; 98 + cursor: pointer; 99 + padding: 0; 100 + margin: 0; 101 + font-size: 1.25rem; 102 + line-height: 1; 103 + color: var(--pico-muted-color); 104 + opacity: 0.7; 105 + } 106 + .toast-close:hover { 107 + opacity: 1; 108 + } 109 + @keyframes slideIn { 110 + from { 111 + transform: translateX(400px); 112 + opacity: 0; 113 + } 114 + to { 115 + transform: translateX(0); 116 + opacity: 1; 117 + } 118 + } 119 + @keyframes slideOut { 120 + from { 121 + transform: translateX(0); 122 + opacity: 1; 123 + } 124 + to { 125 + transform: translateX(400px); 126 + opacity: 0; 127 + } 128 + } 129 + .toast.closing { 130 + animation: slideOut 0.3s ease-in forwards; 131 + } 132 + </style> 133 + <script> 134 + // Toast notification system 135 + function showToast(message, type = 'info', duration = 3000) { 136 + const container = document.getElementById('toast-container'); 137 + 138 + const toast = document.createElement('div'); 139 + toast.className = `toast ${type}`; 140 + 141 + const messageDiv = document.createElement('div'); 142 + messageDiv.className = 'toast-message'; 143 + messageDiv.textContent = message; 144 + 145 + const closeBtn = document.createElement('button'); 146 + closeBtn.className = 'toast-close'; 147 + closeBtn.innerHTML = '&times;'; 148 + closeBtn.setAttribute('aria-label', 'Close'); 149 + closeBtn.onclick = () => closeToast(toast); 150 + 151 + toast.appendChild(messageDiv); 152 + toast.appendChild(closeBtn); 153 + container.appendChild(toast); 154 + 155 + // Auto-dismiss after duration 156 + setTimeout(() => closeToast(toast), duration); 157 + } 158 + 159 + function closeToast(toast) { 160 + toast.classList.add('closing'); 161 + setTimeout(() => { 162 + if (toast.parentNode) { 163 + toast.parentNode.removeChild(toast); 164 + } 165 + }, 300); // Match animation duration 166 + } 167 + 168 + // HTMX event listeners for list operations 169 + document.body.addEventListener('htmx:afterRequest', function(evt) { 170 + if (evt.detail.successful && evt.detail.verb === 'patch') { 171 + showToast('Task removed from list successfully!', 'success'); 172 + 173 + // Update the task count in the header 174 + updateTaskCount(); 175 + } else if (!evt.detail.successful && evt.detail.verb === 'patch') { 176 + showToast('Failed to remove task from list. Please try again.', 'error'); 177 + } 178 + }); 179 + 180 + // Update the task count displayed in the list header 181 + function updateTaskCount() { 182 + const visibleTasks = document.querySelectorAll('#list-tasks .task-item:not([style*="display: none"])').length; 183 + const countElement = document.querySelector('.list-meta'); 184 + if (countElement) { 185 + const countText = visibleTasks + ' task' + (visibleTasks !== 1 ? 's' : ''); 186 + const updatedText = countElement.textContent.replace(/^\d+ tasks?/, countText); 187 + countElement.textContent = updatedText; 188 + } 189 + } 190 + </script> 191 + </head> 192 + <body> 193 + <!-- Toast notification container --> 194 + <div id="toast-container" class="toast-container"></div> 195 + 196 + <header class="container"> 197 + <nav> 198 + <ul> 199 + <li><strong>AT Todo</strong></li> 200 + </ul> 201 + <ul> 202 + <li><a href="/app">Dashboard</a></li> 203 + <li><a href="/docs">Docs</a></li> 204 + <li><a href="/logout">Logout</a></li> 205 + </ul> 206 + </nav> 207 + </header> 208 + 209 + <main class="container"> 210 + <section class="list-header"> 211 + <h1>{{.Name}}</h1> 212 + {{if .Description}} 213 + <p>{{.Description}}</p> 214 + {{end}} 215 + <div class="list-meta"> 216 + {{len .TaskURIs}} task{{if ne (len .TaskURIs) 1}}s{{end}} 217 + {{if .UpdatedAt}} • Updated: {{formatDate .UpdatedAt}}{{end}} 218 + </div> 219 + </section> 220 + 221 + <section> 222 + <h2>Tasks in this List</h2> 223 + 224 + {{if .TaskURIs}} 225 + <div id="list-tasks"> 226 + <!-- Tasks will be loaded here --> 227 + {{range .Tasks}} 228 + <div class="task-item {{if .Completed}}completed{{end}}"> 229 + <h4>{{.Title}}</h4> 230 + {{if .Description}}<p>{{.Description}}</p>{{end}} 231 + <small>Created: {{formatDate .CreatedAt}}</small> 232 + {{if .CompletedAt}}<small> • Completed: {{formatDate .CompletedAt}}</small>{{end}} 233 + 234 + <div class="task-actions"> 235 + <button 236 + hx-patch="/app/lists" 237 + hx-vals='{"rkey": "{{$.RKey}}", "taskUri": "{{.URI}}", "action": "remove"}' 238 + onclick="this.closest('.task-item').style.display='none'"> 239 + Remove from List 240 + </button> 241 + <a href="/app#task-{{.RKey}}" style="padding: 0.25rem 0.75rem;">View Task</a> 242 + </div> 243 + </div> 244 + {{end}} 245 + </div> 246 + {{else}} 247 + <div class="empty-state"> 248 + <h3>No tasks in this list yet</h3> 249 + <p>Go to the <a href="/app">dashboard</a> to add tasks to this list.</p> 250 + </div> 251 + {{end}} 252 + </section> 253 + 254 + <section style="margin-top: 2rem;"> 255 + <a href="/app" role="button" class="secondary">← Back to Dashboard</a> 256 + </section> 257 + </main> 258 + 259 + <footer class="container"> 260 + <p style="text-align: center; color: var(--pico-muted-color); font-size: 0.875rem;"> 261 + Made with ❤ in PDX! 262 + </p> 263 + <p class="version-info" style="text-align: center; color: var(--pico-muted-color); font-size: 0.75rem;"> 264 + {{getVersion}}-{{getCommitID}} 265 + </p> 266 + </footer> 267 + </body> 268 + </html> 269 + {{end}}
+39
templates/partials/list-item.html
···
··· 1 + {{define "list-item.html"}} 2 + <div class="list-item" id="list-{{.RKey}}"> 3 + <div class="list-view"> 4 + <h4>{{.Name}}</h4> 5 + {{if .Description}}<p>{{.Description}}</p>{{end}} 6 + <small>{{len .TaskURIs}} task{{if ne (len .TaskURIs) 1}}s{{end}}</small> 7 + {{if .UpdatedAt}}<small> • Updated: {{formatDate .UpdatedAt}}</small>{{end}} 8 + </div> 9 + 10 + <div class="list-edit" style="display: none;"> 11 + <form hx-put="/app/lists" hx-target="#list-{{.RKey}}" hx-swap="outerHTML"> 12 + <input type="hidden" name="rkey" value="{{.RKey}}"> 13 + <label>Name 14 + <input type="text" name="name" value="{{.Name}}" required> 15 + </label> 16 + <label>Description 17 + <textarea name="description" rows="2">{{.Description}}</textarea> 18 + </label> 19 + <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 20 + <button type="submit">Save</button> 21 + <button type="button" onclick="cancelListEdit('{{.RKey}}')">Cancel</button> 22 + </div> 23 + </form> 24 + </div> 25 + 26 + <div class="list-actions"> 27 + <a href="/app/lists/view/{{.RKey}}" style="padding: 0.25rem 0.75rem;">View Tasks</a> 28 + <button onclick="startListEdit('{{.RKey}}')">Edit</button> 29 + <button class="delete" 30 + hx-delete="/app/lists?rkey={{.RKey}}" 31 + hx-target="#list-{{.RKey}}" 32 + hx-swap="outerHTML" 33 + hx-confirm="Are you sure you want to delete this list? This will not delete the tasks, only the list." 34 + onclick="this.closest('.list-item').classList.add('list-removing')"> 35 + Delete 36 + </button> 37 + </div> 38 + </div> 39 + {{end}}
+13
templates/partials/task-item.html
··· 9 {{if .CompletedAt}} 10 <small> • Completed: {{formatDate .CompletedAt}}</small> 11 {{end}} 12 </div> 13 14 <div class="task-edit" style="display: none;"> ··· 34 </div> 35 36 <div class="task-actions"> 37 <button onclick="startEdit('{{.RKey}}')"> 38 Edit 39 </button>
··· 9 {{if .CompletedAt}} 10 <small> • Completed: {{formatDate .CompletedAt}}</small> 11 {{end}} 12 + {{if .Lists}} 13 + <div style="margin-top: 0.5rem;"> 14 + <small style="color: var(--pico-muted-color);"> 15 + In lists: 16 + {{range $index, $list := .Lists}} 17 + {{if $index}}, {{end}}<a href="/app/lists/view/{{$list.RKey}}" style="font-size: inherit;">{{$list.Name}}</a> 18 + {{end}} 19 + </small> 20 + </div> 21 + {{end}} 22 </div> 23 24 <div class="task-edit" style="display: none;"> ··· 44 </div> 45 46 <div class="task-actions"> 47 + <button onclick="showAddToList('{{.RKey}}', '{{.URI}}')"> 48 + Add to List 49 + </button> 50 <button onclick="startEdit('{{.RKey}}')"> 51 Edit 52 </button>