A quick vibecoded webapp on exe.dev that I liked enough to save the source for.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add ability to edit interaction notes

Ubuntu 110d279b 87471381

+165 -6
+14
db/dbgen/contacts.sql.go
··· 210 210 ) 211 211 return err 212 212 } 213 + 214 + const updateInteractionNotes = `-- name: UpdateInteractionNotes :exec 215 + UPDATE interactions SET notes = ? WHERE id = ? 216 + ` 217 + 218 + type UpdateInteractionNotesParams struct { 219 + Notes *string `json:"notes"` 220 + ID int64 `json:"id"` 221 + } 222 + 223 + func (q *Queries) UpdateInteractionNotes(ctx context.Context, arg UpdateInteractionNotesParams) error { 224 + _, err := q.db.ExecContext(ctx, updateInteractionNotes, arg.Notes, arg.ID) 225 + return err 226 + }
+3
db/queries/contacts.sql
··· 30 30 31 31 -- name: DeleteInteraction :exec 32 32 DELETE FROM interactions WHERE id = ?; 33 + 34 + -- name: UpdateInteractionNotes :exec 35 + UPDATE interactions SET notes = ? WHERE id = ?;
+47
srv/server.go
··· 269 269 http.Redirect(w, r, fmt.Sprintf("/contact/%d", contactID), http.StatusSeeOther) 270 270 } 271 271 272 + func (s *Server) HandleUpdateInteraction(w http.ResponseWriter, r *http.Request) { 273 + userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID")) 274 + if userID == "" { 275 + http.Redirect(w, r, "/__exe.dev/login", http.StatusSeeOther) 276 + return 277 + } 278 + 279 + contactIDStr := r.PathValue("id") 280 + contactID, err := strconv.ParseInt(contactIDStr, 10, 64) 281 + if err != nil { 282 + http.Error(w, "Invalid contact ID", http.StatusBadRequest) 283 + return 284 + } 285 + 286 + interactionIDStr := r.PathValue("interactionId") 287 + interactionID, err := strconv.ParseInt(interactionIDStr, 10, 64) 288 + if err != nil { 289 + http.Error(w, "Invalid interaction ID", http.StatusBadRequest) 290 + return 291 + } 292 + 293 + q := dbgen.New(s.DB) 294 + // Verify contact belongs to user 295 + _, err = q.GetContact(r.Context(), dbgen.GetContactParams{ID: contactID, UserID: userID}) 296 + if err != nil { 297 + http.Error(w, "Contact not found", http.StatusNotFound) 298 + return 299 + } 300 + 301 + notes := strings.TrimSpace(r.FormValue("notes")) 302 + var notesPtr *string 303 + if notes != "" { 304 + notesPtr = &notes 305 + } 306 + 307 + err = q.UpdateInteractionNotes(r.Context(), dbgen.UpdateInteractionNotesParams{ 308 + Notes: notesPtr, 309 + ID: interactionID, 310 + }) 311 + if err != nil { 312 + slog.Warn("update interaction", "error", err) 313 + } 314 + 315 + http.Redirect(w, r, fmt.Sprintf("/contact/%d", contactID), http.StatusSeeOther) 316 + } 317 + 272 318 func (s *Server) HandleDeleteContact(w http.ResponseWriter, r *http.Request) { 273 319 userID := strings.TrimSpace(r.Header.Get("X-ExeDev-UserID")) 274 320 if userID == "" { ··· 355 401 mux.HandleFunc("POST /contact/{id}", s.HandleUpdateContact) 356 402 mux.HandleFunc("POST /contact/{id}/delete", s.HandleDeleteContact) 357 403 mux.HandleFunc("POST /contact/{id}/interaction", s.HandleLogInteraction) 404 + mux.HandleFunc("POST /contact/{id}/interaction/{interactionId}", s.HandleUpdateInteraction) 358 405 mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.StaticDir)))) 359 406 slog.Info("starting server", "addr", addr) 360 407 return http.ListenAndServe(addr, mux)
+69
srv/static/style.css
··· 344 344 border-bottom: none; 345 345 } 346 346 347 + .interaction-header { 348 + display: flex; 349 + justify-content: space-between; 350 + align-items: center; 351 + } 352 + 347 353 .interaction-list .date { 348 354 font-size: 0.9rem; 349 355 color: #7f8c8d; ··· 356 362 .interaction-list .notes.empty { 357 363 color: #bdc3c7; 358 364 font-style: italic; 365 + } 366 + 367 + .edit-btn { 368 + padding: 0.25rem 0.5rem; 369 + background: #ecf0f1; 370 + border: 1px solid #bdc3c7; 371 + border-radius: 4px; 372 + font-size: 0.8rem; 373 + cursor: pointer; 374 + color: #7f8c8d; 375 + } 376 + 377 + .edit-btn:hover { 378 + background: #bdc3c7; 379 + color: #2c3e50; 380 + } 381 + 382 + .notes-edit { 383 + margin-top: 0.5rem; 384 + } 385 + 386 + .notes-edit textarea { 387 + width: 100%; 388 + padding: 0.5rem; 389 + border: 1px solid #ddd; 390 + border-radius: 4px; 391 + font-family: inherit; 392 + font-size: 0.95rem; 393 + resize: vertical; 394 + min-height: 60px; 395 + } 396 + 397 + .edit-actions { 398 + display: flex; 399 + gap: 0.5rem; 400 + margin-top: 0.5rem; 401 + } 402 + 403 + .edit-actions button { 404 + padding: 0.4rem 0.8rem; 405 + border-radius: 4px; 406 + font-size: 0.9rem; 407 + cursor: pointer; 408 + } 409 + 410 + .edit-actions button[type="submit"] { 411 + background: #3498db; 412 + color: white; 413 + border: none; 414 + } 415 + 416 + .edit-actions button[type="submit"]:hover { 417 + background: #2980b9; 418 + } 419 + 420 + .edit-actions button[type="button"] { 421 + background: #ecf0f1; 422 + border: 1px solid #bdc3c7; 423 + color: #7f8c8d; 424 + } 425 + 426 + .edit-actions button[type="button"]:hover { 427 + background: #bdc3c7; 359 428 } 360 429 361 430 /* Empty states */
+32 -6
srv/templates/contact.html
··· 61 61 <ul class="interaction-list"> 62 62 {{range .Interactions}} 63 63 <li> 64 - <span class="date">{{.InteractedAt.Format "Jan 2, 2006 3:04 PM"}}</span> 65 - {{if .Notes}} 66 - <p class="notes">{{.Notes}}</p> 67 - {{else}} 68 - <p class="notes empty">No notes</p> 69 - {{end}} 64 + <div class="interaction-header"> 65 + <span class="date">{{.InteractedAt.Format "Jan 2, 2006 3:04 PM"}}</span> 66 + <button type="button" class="edit-btn" onclick="toggleEdit({{.ID}})">Edit</button> 67 + </div> 68 + <div class="notes-display" id="notes-display-{{.ID}}"> 69 + {{if .Notes}} 70 + <p class="notes">{{.Notes}}</p> 71 + {{else}} 72 + <p class="notes empty">No notes</p> 73 + {{end}} 74 + </div> 75 + <form method="POST" action="/contact/{{$.Contact.ID}}/interaction/{{.ID}}" class="notes-edit" id="notes-edit-{{.ID}}" style="display: none;"> 76 + <textarea name="notes" placeholder="Notes (optional)">{{if .Notes}}{{.Notes}}{{end}}</textarea> 77 + <div class="edit-actions"> 78 + <button type="submit">Save</button> 79 + <button type="button" onclick="toggleEdit({{.ID}})">Cancel</button> 80 + </div> 81 + </form> 70 82 </li> 71 83 {{end}} 72 84 </ul> ··· 74 86 <p class="empty">No interactions logged yet.</p> 75 87 {{end}} 76 88 </section> 89 + 90 + <script> 91 + function toggleEdit(id) { 92 + var display = document.getElementById('notes-display-' + id); 93 + var edit = document.getElementById('notes-edit-' + id); 94 + if (edit.style.display === 'none') { 95 + display.style.display = 'none'; 96 + edit.style.display = 'block'; 97 + } else { 98 + display.style.display = 'block'; 99 + edit.style.display = 'none'; 100 + } 101 + } 102 + </script> 77 103 78 104 <a href="/" class="back-link">← Back to all contacts</a> 79 105 </main>