allow to name checklist

This commit is contained in:
lubiana 2025-07-25 20:06:40 +02:00
parent c7eae58857
commit eb023a40d0
Signed by: lubiana
SSH key fingerprint: SHA256:vW1EA0fRR3Fw+dD/sM0K+x3Il2gSry6YRYHqOeQwrfk
6 changed files with 202 additions and 2 deletions

View file

@ -0,0 +1,118 @@
import React, { useState, useRef, useEffect } from 'react'
interface EditableTitleProps {
title: string
onSave: (newTitle: string) => Promise<void>
className?: string
}
export const EditableTitle: React.FC<EditableTitleProps> = ({
title,
onSave,
className = ''
}) => {
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(title)
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setEditValue(title)
}, [title])
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
const handleStartEdit = () => {
setIsEditing(true)
setEditValue(title)
}
const handleSave = async () => {
const trimmedValue = editValue.trim()
if (trimmedValue === title || trimmedValue === '') {
setIsEditing(false)
return
}
setIsSaving(true)
try {
await onSave(trimmedValue)
setIsEditing(false)
} catch (error) {
console.error('Failed to save title:', error)
// Reset to original value on error
setEditValue(title)
} finally {
setIsSaving(false)
}
}
const handleCancel = () => {
setIsEditing(false)
setEditValue(title)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancel()
}
}
if (isEditing) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
disabled={isSaving}
className="flex-1 px-2 py-1 text-base font-bold text-gray-900 dark:text-white bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
maxLength={100}
/>
{isSaving && (
<div className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
<span>Saving...</span>
</div>
)}
</div>
)
}
return (
<button
onClick={handleStartEdit}
className={`text-base font-bold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 group ${className}`}
title="Click to edit title"
>
<span className="flex items-center gap-1">
{title}
<svg
className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</span>
</button>
)
}

View file

@ -10,6 +10,7 @@ export function saveChecklist(checklist: SavedChecklist) {
if (existingIndex > -1) { if (existingIndex > -1) {
// Update lastOpened timestamp if checklist already exists // Update lastOpened timestamp if checklist already exists
existingChecklists[existingIndex].lastOpened = new Date().toISOString(); existingChecklists[existingIndex].lastOpened = new Date().toISOString();
existingChecklists[existingIndex].name = checklist.name;
} else { } else {
// Add new checklist with lastOpened timestamp // Add new checklist with lastOpened timestamp
existingChecklists.push({ ...checklist, lastOpened: new Date().toISOString() }); existingChecklists.push({ ...checklist, lastOpened: new Date().toISOString() });

View file

@ -28,6 +28,7 @@ export function useSSE(uuid: string) {
switch (data.type) { switch (data.type) {
case 'checklist_name': case 'checklist_name':
case 'checklist_name_updated':
if (data.name) { if (data.name) {
saveChecklist({ saveChecklist({
uuid, uuid,

View file

@ -2,6 +2,7 @@ import { useState } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useSSE } from '../hooks/useSSE' import { useSSE } from '../hooks/useSSE'
import ChecklistItem from '../components/ChecklistItem' import ChecklistItem from '../components/ChecklistItem'
import { EditableTitle } from '../components/EditableTitle'
import type { ChecklistItem as ChecklistItemType } from '../types' import type { ChecklistItem as ChecklistItemType } from '../types'
interface ItemGroup { interface ItemGroup {
@ -204,6 +205,22 @@ export default function Checklist() {
} }
} }
const updateChecklistName = async (newName: string) => {
if (!uuid) throw new Error('No checklist UUID')
const response = await fetch(`/api/checklists/${uuid}/name`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newName }),
})
if (!response.ok) {
throw new Error('Failed to update checklist name')
}
}
const handleAddItem = async () => { const handleAddItem = async () => {
await addItem(newItemContent) await addItem(newItemContent)
setNewItemContent('') setNewItemContent('')
@ -353,7 +370,11 @@ export default function Checklist() {
<span className="text-base">Back</span> <span className="text-base">Back</span>
</button> </button>
</Link> </Link>
<h1 className="text-base font-bold text-gray-900 dark:text-white">{checkListName}</h1> <EditableTitle
title={checkListName}
onSave={updateChecklistName}
className="text-base"
/>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isConnected ? ( {isConnected ? (

View file

@ -20,7 +20,7 @@ export interface SavedChecklist {
} }
export interface SSEEvent { export interface SSEEvent {
type: 'full_state' | 'item_added' | 'item_updated' | 'item_deleted' | 'item_locked' | 'item_unlocked' | 'checklist_name' type: 'full_state' | 'item_added' | 'item_updated' | 'item_deleted' | 'item_locked' | 'item_unlocked' | 'checklist_name' | 'checklist_name_updated'
name?: string name?: string
items?: ChecklistItem[] items?: ChecklistItem[]
item?: ChecklistItem item?: ChecklistItem

59
main.go
View file

@ -243,6 +243,17 @@ func addChecklist(name string) (string, error) {
return uuidStr, err return uuidStr, err
} }
func updateChecklistName(uuid string, name string) error {
db, err := getChecklistDB(uuid)
if err != nil {
return err
}
defer db.Close()
_, err = db.Exec(`UPDATE checklist_info SET name = ? WHERE uuid = ?`, name, uuid)
return err
}
func addItem(uuid, content string, parentID *int, notBefore *time.Time, notAfter *time.Time) (ChecklistItem, error) { func addItem(uuid, content string, parentID *int, notBefore *time.Time, notAfter *time.Time) (ChecklistItem, error) {
db, err := getChecklistDB(uuid) db, err := getChecklistDB(uuid)
if err != nil { if err != nil {
@ -479,11 +490,16 @@ func deleteItem(uuid string, id int) error {
func broadcast(uuid string, msg interface{}) { func broadcast(uuid string, msg interface{}) {
js, _ := json.Marshal(msg) js, _ := json.Marshal(msg)
log.Printf("Broadcasting to %s: %s", uuid, string(js))
sseClientsMutex.Lock() sseClientsMutex.Lock()
clientCount := len(sseClients[uuid])
log.Printf("Number of SSE clients for %s: %d", uuid, clientCount)
for ch := range sseClients[uuid] { for ch := range sseClients[uuid] {
select { select {
case ch <- string(js): case ch <- string(js):
log.Printf("Message sent to client")
default: default:
log.Printf("Channel full, skipping message")
// skip if channel is full (consider logging in prod!) // skip if channel is full (consider logging in prod!)
} }
} }
@ -688,6 +704,46 @@ func handleLockItem(w http.ResponseWriter, r *http.Request) {
}) })
} }
func handleUpdateChecklistName(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, "/")
uuid := parts[3]
log.Printf("handleUpdateChecklistName called for uuid: %s", uuid)
// Ensure checklist exists
if err := ensureChecklistExists(uuid); err != nil {
log.Printf("Failed to ensure checklist exists: %v", err)
http.Error(w, "Failed to ensure checklist exists", 500)
return
}
type Req struct {
Name string `json:"name"`
}
var req Req
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
log.Printf("Invalid request body: %v", err)
http.Error(w, "Missing or empty name", 400)
return
}
log.Printf("Updating checklist name to: %s", req.Name)
if err := updateChecklistName(uuid, req.Name); err != nil {
log.Printf("Failed to update checklist name: %v", err)
http.Error(w, "Failed to update checklist name", 500)
return
}
log.Printf("Checklist name updated successfully, broadcasting...")
// Broadcast name update
broadcast(uuid, map[string]interface{}{"type": "checklist_name_updated", "name": req.Name})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Checklist name updated successfully",
"name": req.Name,
})
}
func lockExpiryDaemon() { func lockExpiryDaemon() {
for { for {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
@ -810,6 +866,9 @@ func main() {
log.Printf("API request: %s %s", r.Method, path) log.Printf("API request: %s %s", r.Method, path)
switch { switch {
case strings.HasSuffix(path, "/name") && r.Method == "PATCH":
log.Printf("Handling PATCH checklist name")
handleUpdateChecklistName(w, r)
case strings.HasSuffix(path, "/items") && r.Method == "GET": case strings.HasSuffix(path, "/items") && r.Method == "GET":
log.Printf("Handling GET items") log.Printf("Handling GET items")
handleGetItems(w, r) handleGetItems(w, r)