From f2915b79fa9774b5f9982f6b5f6f44157173aef0 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 30 Mar 2026 10:53:23 +0000 Subject: [PATCH] feat(tasks): add inline title editing and status cycling in task list Click task title to edit inline (Enter/blur saves via PUT API). Click status dot to cycle pending -> in_progress -> done. Optimistic UI updates with rollback on error. Detail page still accessible by clicking other card areas. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/tasks/app/tasks/page.tsx | 1 + apps/tasks/components/TaskCard.tsx | 305 +++++++++++++++++++++++++---- 2 files changed, 268 insertions(+), 38 deletions(-) diff --git a/apps/tasks/app/tasks/page.tsx b/apps/tasks/app/tasks/page.tsx index 16dc487..3f5500f 100644 --- a/apps/tasks/app/tasks/page.tsx +++ b/apps/tasks/app/tasks/page.tsx @@ -315,6 +315,7 @@ export default function TasksPage() { key={task.id} task={task} onComplete={handleCompleteTask} + onUpdate={loadData} /> ))} diff --git a/apps/tasks/components/TaskCard.tsx b/apps/tasks/components/TaskCard.tsx index 3ab4822..f62fdc8 100644 --- a/apps/tasks/components/TaskCard.tsx +++ b/apps/tasks/components/TaskCard.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useRef } from "react"; -import { Task } from "@/lib/api"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { Task, updateTask } from "@/lib/api"; +import { useAuth } from "@/lib/auth"; import { useTranslation } from "@/lib/i18n"; import Link from "next/link"; import { useSwipeable } from "react-swipeable"; @@ -10,8 +11,11 @@ interface TaskCardProps { task: Task; onComplete?: (taskId: string) => void; onAssign?: (taskId: string) => void; + onUpdate?: () => void; } +const STATUS_CYCLE: Task["status"][] = ["pending", "in_progress", "done"]; + function statusColor(status: string): string { switch (status) { case "todo": @@ -50,18 +54,42 @@ function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } -export default function TaskCard({ task, onComplete, onAssign }: TaskCardProps) { +export default function TaskCard({ task, onComplete, onAssign, onUpdate }: TaskCardProps) { const { t } = useTranslation(); + const { token } = useAuth(); const taskDone = task.status === "done"; const [swipeOffset, setSwipeOffset] = useState(0); const [swiped, setSwiped] = useState(false); const cardRef = useRef(null); + // Inline editing state + const [editing, setEditing] = useState(false); + const [editTitle, setEditTitle] = useState(task.title); + const [saving, setSaving] = useState(false); + const inputRef = useRef(null); + + // Status cycling state + const [currentStatus, setCurrentStatus] = useState(task.status); + + // Sync with prop changes + useEffect(() => { + setEditTitle(task.title); + setCurrentStatus(task.status); + }, [task.title, task.status]); + + // Focus input when entering edit mode + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.setSelectionRange(0, inputRef.current.value.length); + } + }, [editing]); + const SWIPE_THRESHOLD = 120; const assignees = task.assigned_to || []; const visibleAssignees = assignees.slice(0, 3); const groupColor = task.group_color; - const sColor = statusColor(task.status); + const sColor = statusColor(currentStatus); const swipeHandlers = useSwipeable({ onSwiping: (e) => { @@ -88,6 +116,67 @@ export default function TaskCard({ task, onComplete, onAssign }: TaskCardProps) const showCompleteHint = swipeOffset > 40; + // Save title edit + const saveTitle = useCallback(async () => { + const trimmed = editTitle.trim(); + if (!trimmed || trimmed === task.title || !token) { + setEditTitle(task.title); + setEditing(false); + return; + } + setSaving(true); + try { + await updateTask(token, task.id, { title: trimmed }); + setEditing(false); + if (onUpdate) onUpdate(); + } catch (err) { + console.error("Failed to update title:", err); + setEditTitle(task.title); + setEditing(false); + } finally { + setSaving(false); + } + }, [editTitle, task.title, task.id, token, onUpdate]); + + // Cycle status on dot click + const cycleStatus = useCallback(async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!token) return; + + const idx = STATUS_CYCLE.indexOf(currentStatus); + const nextStatus = STATUS_CYCLE[(idx + 1) % STATUS_CYCLE.length]; + + // Optimistic update + setCurrentStatus(nextStatus); + try { + await updateTask(token, task.id, { status: nextStatus }); + if (onUpdate) onUpdate(); + } catch (err) { + console.error("Failed to cycle status:", err); + setCurrentStatus(task.status); // revert + } + }, [token, task.id, task.status, currentStatus, onUpdate]); + + // Handle title click -> enter edit mode + const handleTitleClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setEditing(true); + }, []); + + // Handle keyboard in edit input + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + saveTitle(); + } + if (e.key === "Escape") { + setEditTitle(task.title); + setEditing(false); + } + }, [saveTitle, task.title]); + return (
{/* Swipe background */} @@ -117,7 +206,8 @@ export default function TaskCard({ task, onComplete, onAssign }: TaskCardProps) transition: swipeOffset === 0 ? "transform 0.3s ease" : "none", }} > - + {/* When editing, don't wrap in Link - just show the card with input */} + {editing ? (
- {/* LEFT: title + optional due date */} + {/* LEFT: editable input */}
-
- {task.title} -
- {task.due_at && isDueSoon(task.due_at) && ( -
- {formatDate(task.due_at)} -
- )} + setEditTitle(e.target.value)} + onBlur={saveTitle} + onKeyDown={handleKeyDown} + disabled={saving} + style={{ + width: "100%", + fontSize: 14, + fontWeight: 500, + color: "#E8E8F0", + background: "#1A1A28", + border: "1px solid #3B82F6", + borderRadius: 6, + padding: "4px 8px", + outline: "none", + boxShadow: "0 0 0 2px rgba(59,130,246,0.3)", + opacity: saving ? 0.6 : 1, + }} + />
- {/* RIGHT: avatars + big status dot */} + {/* RIGHT: avatars + status dot (still interactive) */}
- {/* Avatars */}
{visibleAssignees.map((userId, i) => (
))} - - {/* + add user button */}
{ e.preventDefault(); e.stopPropagation(); if (onAssign) onAssign(task.id); }} - title="Přidat uživatele" + title="Pridat uzivatele" style={{ width: 26, height: 26, borderRadius: "50%", @@ -208,20 +293,164 @@ export default function TaskCard({ task, onComplete, onAssign }: TaskCardProps)
- {/* Big colored status dot */} + {/* Status dot - clickable to cycle */}
{ + (e.currentTarget as HTMLElement).style.transform = "scale(1.3)"; + (e.currentTarget as HTMLElement).style.boxShadow = `0 0 10px ${sColor}`; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.transform = "scale(1)"; + (e.currentTarget as HTMLElement).style.boxShadow = `0 0 6px ${sColor}80`; }} />
- + ) : ( + /* Normal mode: Link wrapping for navigation on non-interactive areas */ + +
+ {/* LEFT: title (clickable for inline edit) + optional due date */} +
+
{ + (e.currentTarget as HTMLElement).style.background = "rgba(59,130,246,0.1)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = "transparent"; + }} + title="Click to edit title" + > + {task.title} +
+ {task.due_at && isDueSoon(task.due_at) && ( +
+ {formatDate(task.due_at)} +
+ )} +
+ + {/* RIGHT: avatars + big status dot */} +
+ {/* Avatars */} +
+ {visibleAssignees.map((userId, i) => ( +
0 ? -8 : 0, + border: "2px solid #13131A", + background: userColor(userId), + display: "flex", alignItems: "center", justifyContent: "center", + fontSize: 10, fontWeight: 700, color: "white", + zIndex: 3 - i, + position: "relative", + flexShrink: 0, + }} + > + {userId.slice(0, 2).toUpperCase()} +
+ ))} + + {/* + add user button */} +
{ + e.preventDefault(); + e.stopPropagation(); + if (onAssign) onAssign(task.id); + }} + title="Pridat uzivatele" + style={{ + width: 26, height: 26, + borderRadius: "50%", + marginLeft: visibleAssignees.length > 0 ? -8 : 0, + border: "2px dashed #3A3A5A", + background: "transparent", + cursor: "pointer", + display: "flex", alignItems: "center", justifyContent: "center", + fontSize: 14, color: "#4A4A6A", + position: "relative", zIndex: 0, + flexShrink: 0, + }} + > + + +
+
+ + {/* Big colored status dot - clickable to cycle */} +
{ + (e.currentTarget as HTMLElement).style.transform = "scale(1.3)"; + (e.currentTarget as HTMLElement).style.boxShadow = `0 0 10px ${sColor}`; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.transform = "scale(1)"; + (e.currentTarget as HTMLElement).style.boxShadow = `0 0 6px ${sColor}80`; + }} + /> +
+
+ + )}
);