allow to name checklist
This commit is contained in:
parent
c7eae58857
commit
eb023a40d0
6 changed files with 202 additions and 2 deletions
118
frontend/src/components/EditableTitle.tsx
Normal file
118
frontend/src/components/EditableTitle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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() });
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 ? (
|
||||||
|
|
|
@ -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
59
main.go
|
@ -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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue