Add inline editing, delete/archive buttons, and Won't Do column to aggregator

- Add delete (×) and archive (↑) hover buttons on task cards
- Add Won't Do as fourth kanban column (grid now 4 columns)
- Implement inline title editing (click to edit, Enter/Escape)
- Add status dropdown for quick status changes
- Add priority dropdown with color-coded display
- Make description editable when card is expanded (Ctrl+Enter to save)
- Add POST /api/tasks/archive endpoint (moves to archive folder)
- Add DELETE /api/tasks/delete endpoint (permanent deletion)
- Enhance PATCH /api/tasks/update to support all fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-04 01:44:40 -08:00
parent 6421377c86
commit 10ee1094c1
5 changed files with 600 additions and 75 deletions

View File

@ -10,16 +10,15 @@
import { type Server, type ServerWebSocket, $ } from "bun";
import { watch, type FSWatcher } from "node:fs";
import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises";
import { join, basename, dirname, relative } from "node:path";
import { readdir, stat, readFile, writeFile, mkdir, unlink } from "node:fs/promises";
import { join, basename, dirname } from "node:path";
import { parseTask } from "../markdown/parser.ts";
import type { Task } from "../types/index.ts";
import { sortByTaskId } from "../utils/task-sorting.ts";
import { fileURLToPath } from "node:url";
// @ts-expect-error - Bun file import
import favicon from "../web/favicon.png" with { type: "file" };
import { dirname, join as pathJoin } from "node:path";
import { fileURLToPath } from "node:url";
// Get the directory of this file
const __filename = fileURLToPath(import.meta.url);
@ -114,6 +113,12 @@ export class BacklogAggregator {
"/api/tasks/create": {
POST: async (req: Request) => this.handleCreateTask(req),
},
"/api/tasks/archive": {
POST: async (req: Request) => this.handleArchiveTask(req),
},
"/api/tasks/delete": {
DELETE: async (req: Request) => this.handleDeleteTask(req),
},
},
fetch: async (req: Request, server: Server) => {
const url = new URL(req.url);
@ -137,7 +142,7 @@ export class BacklogAggregator {
// Serve the HTML app for root path
if (url.pathname === "/" || url.pathname === "") {
const htmlPath = pathJoin(__dirname, "web", "index.html");
const htmlPath = join(__dirname, "web", "index.html");
const htmlFile = Bun.file(htmlPath);
return new Response(htmlFile, { headers: { "Content-Type": "text/html" } });
}
@ -510,14 +515,19 @@ export class BacklogAggregator {
private async handleUpdateTask(req: Request): Promise<Response> {
try {
const body = await req.json();
const { projectPath, taskId, status } = body as {
const { projectPath, taskId, status, title, description, priority, labels, assignee } = body as {
projectPath: string;
taskId: string;
status: string;
status?: string;
title?: string;
description?: string;
priority?: string;
labels?: string[];
assignee?: string[];
};
if (!projectPath || !taskId || !status) {
return Response.json({ error: "Missing required fields: projectPath, taskId, status" }, { status: 400 });
if (!projectPath || !taskId) {
return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 });
}
// Find the task
@ -540,20 +550,49 @@ export class BacklogAggregator {
return Response.json({ error: "Task file not found" }, { status: 404 });
}
// Update the status in the frontmatter
const updatedContent = this.updateTaskStatus(content, status);
// Update the fields in the frontmatter
let updatedContent = content;
if (status !== undefined) {
updatedContent = this.updateTaskField(updatedContent, "status", status);
}
if (title !== undefined) {
updatedContent = this.updateTaskField(updatedContent, "title", title);
}
if (priority !== undefined) {
updatedContent = this.updateTaskField(updatedContent, "priority", priority);
}
if (labels !== undefined) {
updatedContent = this.updateTaskField(updatedContent, "labels", `[${labels.join(", ")}]`);
}
if (assignee !== undefined) {
updatedContent = this.updateTaskField(updatedContent, "assignee", `[${assignee.join(", ")}]`);
}
if (description !== undefined) {
updatedContent = this.updateTaskDescription(updatedContent, description);
}
// Add updated_date
updatedContent = this.addUpdatedDate(updatedContent);
// Write the updated content back
await writeFile(taskFilePath, updatedContent, "utf-8");
console.log(`Task ${taskId} status updated to "${status}" in ${this.projects.get(projectPath)?.name}`);
const updatedFields = [];
if (status !== undefined) updatedFields.push(`status="${status}"`);
if (title !== undefined) updatedFields.push(`title="${title}"`);
if (priority !== undefined) updatedFields.push(`priority="${priority}"`);
if (labels !== undefined) updatedFields.push(`labels=[${labels.join(", ")}]`);
if (assignee !== undefined) updatedFields.push(`assignee=[${assignee.join(", ")}]`);
if (description !== undefined) updatedFields.push("description updated");
console.log(`Task ${taskId} updated (${updatedFields.join(", ")}) in ${this.projects.get(projectPath)?.name}`);
// The file watcher will pick up the change and broadcast update
// But we can also force an immediate update
await this.loadTask(projectPath, taskFilePath);
this.broadcastUpdate();
return Response.json({ success: true, taskId, status });
return Response.json({ success: true, taskId, updated: updatedFields });
} catch (error) {
console.error("Error updating task:", error);
return Response.json({ error: "Failed to update task" }, { status: 500 });
@ -636,38 +675,167 @@ created_date: '${dateStr}'
}
}
private async handleArchiveTask(req: Request): Promise<Response> {
try {
const body = await req.json();
const { projectPath, taskId } = body as {
projectPath: string;
taskId: string;
};
if (!projectPath || !taskId) {
return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 });
}
// Find the task
const taskKey = `${projectPath}:${taskId}`;
const task = this.tasks.get(taskKey);
if (!task) {
return Response.json({ error: "Task not found" }, { status: 404 });
}
// Find the task file path
const tasksDir = join(projectPath, "backlog", "tasks");
const archiveDir = join(projectPath, "backlog", "archive", "tasks");
const taskFileName = `${taskId}.md`;
const taskFilePath = join(tasksDir, taskFileName);
const archiveFilePath = join(archiveDir, taskFileName);
// Ensure archive directory exists
try {
await mkdir(archiveDir, { recursive: true });
} catch {
// Directory might already exist
}
// Read the task file
let content: string;
try {
content = await readFile(taskFilePath, "utf-8");
} catch {
return Response.json({ error: "Task file not found" }, { status: 404 });
}
// Move to archive (write to archive, delete from tasks)
await writeFile(archiveFilePath, content, "utf-8");
await unlink(taskFilePath);
// Remove from in-memory tasks
this.tasks.delete(taskKey);
console.log(`Task ${taskId} archived in ${this.projects.get(projectPath)?.name}`);
this.broadcastUpdate();
return Response.json({ success: true, taskId, archived: true });
} catch (error) {
console.error("Error archiving task:", error);
return Response.json({ error: "Failed to archive task" }, { status: 500 });
}
}
private async handleDeleteTask(req: Request): Promise<Response> {
try {
const body = await req.json();
const { projectPath, taskId } = body as {
projectPath: string;
taskId: string;
};
if (!projectPath || !taskId) {
return Response.json({ error: "Missing required fields: projectPath, taskId" }, { status: 400 });
}
// Find the task
const taskKey = `${projectPath}:${taskId}`;
const task = this.tasks.get(taskKey);
if (!task) {
return Response.json({ error: "Task not found" }, { status: 404 });
}
// Find the task file path
const tasksDir = join(projectPath, "backlog", "tasks");
const taskFileName = `${taskId}.md`;
const taskFilePath = join(tasksDir, taskFileName);
// Delete the file
try {
await unlink(taskFilePath);
} catch {
return Response.json({ error: "Task file not found" }, { status: 404 });
}
// Remove from in-memory tasks
this.tasks.delete(taskKey);
console.log(`Task ${taskId} deleted from ${this.projects.get(projectPath)?.name}`);
this.broadcastUpdate();
return Response.json({ success: true, taskId, deleted: true });
} catch (error) {
console.error("Error deleting task:", error);
return Response.json({ error: "Failed to delete task" }, { status: 500 });
}
}
private updateTaskStatus(content: string, newStatus: string): string {
// Parse frontmatter and update status
return this.updateTaskField(content, "status", newStatus);
}
private updateTaskField(content: string, field: string, value: string): string {
// Parse frontmatter and update the field
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
if (!frontmatterMatch || !frontmatterMatch[1]) {
return content;
}
const frontmatter = frontmatterMatch[1];
const updatedFrontmatter = frontmatter.replace(
/^status:\s*.+$/m,
`status: ${newStatus}`,
);
const fieldRegex = new RegExp(`^${field}:\\s*.+$`, "m");
// Add updated_date
let updatedFrontmatter: string;
if (fieldRegex.test(frontmatter)) {
// Update existing field
updatedFrontmatter = frontmatter.replace(fieldRegex, `${field}: ${value}`);
} else {
// Add new field
updatedFrontmatter = frontmatter + `\n${field}: ${value}`;
}
return content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`);
}
private updateTaskDescription(content: string, newDescription: string): string {
// Find and replace the description section
const descriptionRegex = /## Description\n\n[\s\S]*?(?=\n## |\n---|\Z)/;
const newDescriptionSection = `## Description\n\n${newDescription}\n`;
if (descriptionRegex.test(content)) {
return content.replace(descriptionRegex, newDescriptionSection);
}
// If no description section, add one after frontmatter
return content.replace(/^(---\n[\s\S]*?\n---\n\n?)/, `$1${newDescriptionSection}\n`);
}
private addUpdatedDate(content: string): string {
const now = new Date();
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
let finalFrontmatter = updatedFrontmatter;
if (finalFrontmatter.includes("updated_date:")) {
finalFrontmatter = finalFrontmatter.replace(
/^updated_date:\s*.+$/m,
`updated_date: '${dateStr}'`,
);
} else {
// Add updated_date before the closing ---
finalFrontmatter += `\nupdated_date: '${dateStr}'`;
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch || !frontmatterMatch[1]) {
return content;
}
return content.replace(
/^---\n[\s\S]*?\n---/,
`---\n${finalFrontmatter}\n---`,
);
const frontmatter = frontmatterMatch[1];
let updatedFrontmatter: string;
if (frontmatter.includes("updated_date:")) {
updatedFrontmatter = frontmatter.replace(/^updated_date:\s*.+$/m, `updated_date: '${dateStr}'`);
} else {
updatedFrontmatter = frontmatter + `\nupdated_date: '${dateStr}'`;
}
return content.replace(/^---\n[\s\S]*?\n---/, `---\n${updatedFrontmatter}\n---`);
}
private async getNextTaskId(projectPath: string): Promise<string> {

View File

@ -36,9 +36,71 @@ function App() {
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [filter, setFilter] = useState<string>("");
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleArchiveTask = async (task: AggregatedTask) => {
try {
const response = await fetch("/api/tasks/archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectPath: task.projectPath, taskId: task.id }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to archive task");
}
setActionError(null);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Failed to archive task");
setTimeout(() => setActionError(null), 3000);
}
};
const handleDeleteTask = async (task: AggregatedTask) => {
if (!confirm(`Are you sure you want to delete "${task.title}"? This cannot be undone.`)) {
return;
}
try {
const response = await fetch("/api/tasks/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectPath: task.projectPath, taskId: task.id }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to delete task");
}
setActionError(null);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Failed to delete task");
setTimeout(() => setActionError(null), 3000);
}
};
const handleUpdateTask = async (task: AggregatedTask, updates: Partial<AggregatedTask>) => {
try {
const response = await fetch("/api/tasks/update", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectPath: task.projectPath,
taskId: task.id,
...updates,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to update task");
}
setActionError(null);
} catch (error) {
setActionError(error instanceof Error ? error.message : "Failed to update task");
setTimeout(() => setActionError(null), 3000);
}
};
const connectWebSocket = useCallback(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${window.location.host}`);
@ -85,7 +147,7 @@ function App() {
}, [connectWebSocket]);
// Group tasks by status
const statuses = ["To Do", "In Progress", "Done"];
const statuses = ["To Do", "In Progress", "Done", "Won't Do"];
const tasksByStatus = statuses.reduce(
(acc, status) => {
acc[status] = tasks.filter((t) => {
@ -137,6 +199,19 @@ function App() {
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
{actionError && (
<span
style={{
padding: "0.25rem 0.75rem",
borderRadius: "0.25rem",
fontSize: "0.75rem",
backgroundColor: "#ef4444",
color: "white",
}}
>
{actionError}
</span>
)}
<span style={{ fontSize: "0.875rem", color: "#94a3b8" }}>
{projects.length} projects | {tasks.length} tasks
{lastUpdate && ` | Updated ${lastUpdate.toLocaleTimeString()}`}
@ -216,7 +291,7 @@ function App() {
style={{
padding: "2rem",
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "1.5rem",
minHeight: "calc(100vh - 180px)",
}}
@ -259,7 +334,14 @@ function App() {
{/* Tasks */}
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", flex: 1, overflowY: "auto" }}>
{tasksByStatus[status]?.map((task) => (
<TaskCard key={`${task.projectPath}:${task.id}`} task={task} />
<TaskCard
key={`${task.projectPath}:${task.id}`}
task={task}
onArchive={handleArchiveTask}
onDelete={handleDeleteTask}
onUpdate={handleUpdateTask}
statuses={statuses}
/>
))}
{(!tasksByStatus[status] || tasksByStatus[status].length === 0) && (
<div style={{ color: "#64748b", textAlign: "center", padding: "2rem", fontSize: "0.875rem" }}>
@ -274,8 +356,19 @@ function App() {
);
}
function TaskCard({ task }: { task: AggregatedTask }) {
interface TaskCardProps {
task: AggregatedTask;
onArchive: (task: AggregatedTask) => void;
onDelete: (task: AggregatedTask) => void;
onUpdate: (task: AggregatedTask, updates: Partial<AggregatedTask>) => void;
statuses: string[];
}
function TaskCard({ task, onArchive, onDelete, onUpdate, statuses }: TaskCardProps) {
const [expanded, setExpanded] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [editingField, setEditingField] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const priorityColors: Record<string, string> = {
high: "#ef4444",
@ -283,49 +376,212 @@ function TaskCard({ task }: { task: AggregatedTask }) {
low: "#22c55e",
};
const priorities = ["high", "medium", "low"];
const handleArchiveClick = (e: React.MouseEvent) => {
e.stopPropagation();
onArchive(task);
};
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete(task);
};
const startEditing = (field: string, currentValue: string, e: React.MouseEvent) => {
e.stopPropagation();
setEditingField(field);
setEditValue(currentValue);
};
const saveEdit = async (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
if (editingField && editValue !== task[editingField as keyof AggregatedTask]) {
onUpdate(task, { [editingField]: editValue });
}
setEditingField(null);
setEditValue("");
};
const cancelEdit = (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation();
setEditingField(null);
setEditValue("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
saveEdit(e);
} else if (e.key === "Escape") {
cancelEdit(e);
}
};
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
e.stopPropagation();
onUpdate(task, { status: e.target.value });
};
const handlePriorityChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
e.stopPropagation();
onUpdate(task, { priority: e.target.value });
};
const inputStyle: React.CSSProperties = {
backgroundColor: "#1e293b",
border: "1px solid #3b82f6",
borderRadius: "0.25rem",
padding: "0.25rem 0.5rem",
color: "#e2e8f0",
fontSize: "inherit",
width: "100%",
outline: "none",
};
const selectStyle: React.CSSProperties = {
backgroundColor: "#1e293b",
border: "1px solid #334155",
borderRadius: "0.25rem",
padding: "0.125rem 0.25rem",
color: "#e2e8f0",
fontSize: "0.625rem",
cursor: "pointer",
outline: "none",
};
return (
<div
onClick={() => setExpanded(!expanded)}
onClick={() => !editingField && setExpanded(!expanded)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{
backgroundColor: "#0f172a",
borderRadius: "0.375rem",
padding: "0.75rem",
cursor: "pointer",
cursor: editingField ? "default" : "pointer",
borderLeft: `4px solid ${task.projectColor}`,
transition: "transform 0.1s, box-shadow 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = "translateY(-1px)";
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "none";
position: "relative",
transform: isHovering && !editingField ? "translateY(-1px)" : "translateY(0)",
boxShadow: isHovering && !editingField ? "0 4px 6px -1px rgba(0, 0, 0, 0.3)" : "none",
}}
>
{/* Action buttons - shown on hover */}
{isHovering && !editingField && (
<div
style={{
position: "absolute",
top: "-8px",
right: "-8px",
display: "flex",
gap: "4px",
zIndex: 10,
}}
>
<button
onClick={handleArchiveClick}
title="Archive task"
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
border: "none",
backgroundColor: "#3b82f6",
color: "white",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: "bold",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
}}
>
</button>
<button
onClick={handleDeleteClick}
title="Delete task"
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
border: "none",
backgroundColor: "#ef4444",
color: "white",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
}}
>
×
</button>
</div>
)}
{/* Task Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "0.5rem" }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: "0.875rem", fontWeight: 500, marginBottom: "0.25rem" }}>{task.title}</div>
<div style={{ fontSize: "0.75rem", color: "#64748b" }}>
{task.id} | {task.projectName}
{editingField === "title" ? (
<div style={{ display: "flex", gap: "0.25rem", alignItems: "center" }}>
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
autoFocus
style={{ ...inputStyle, fontSize: "0.875rem", fontWeight: 500 }}
/>
<button onClick={saveEdit} style={{ ...selectStyle, backgroundColor: "#22c55e" }}></button>
<button onClick={cancelEdit} style={{ ...selectStyle, backgroundColor: "#ef4444" }}></button>
</div>
) : (
<div
style={{ fontSize: "0.875rem", fontWeight: 500, marginBottom: "0.25rem", cursor: "text" }}
onClick={(e) => startEditing("title", task.title, e)}
title="Click to edit title"
>
{task.title}
</div>
)}
<div style={{ fontSize: "0.75rem", color: "#64748b", display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span>{task.id} | {task.projectName}</span>
<select
value={task.status}
onChange={handleStatusChange}
onClick={(e) => e.stopPropagation()}
style={{ ...selectStyle, marginLeft: "0.25rem" }}
title="Change status"
>
{statuses.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
{task.priority && (
<span
style={{
padding: "0.125rem 0.375rem",
borderRadius: "0.25rem",
fontSize: "0.625rem",
fontWeight: 500,
textTransform: "uppercase",
backgroundColor: priorityColors[task.priority.toLowerCase()] + "22",
color: priorityColors[task.priority.toLowerCase()],
}}
>
{task.priority}
</span>
)}
<select
value={task.priority || ""}
onChange={handlePriorityChange}
onClick={(e) => e.stopPropagation()}
style={{
...selectStyle,
backgroundColor: task.priority ? priorityColors[task.priority.toLowerCase()] + "22" : "#334155",
color: task.priority ? priorityColors[task.priority.toLowerCase()] : "#94a3b8",
fontWeight: 500,
textTransform: "uppercase",
}}
title="Change priority"
>
<option value="">No priority</option>
{priorities.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
{/* Labels */}
@ -349,19 +605,65 @@ function TaskCard({ task }: { task: AggregatedTask }) {
)}
{/* Expanded Details */}
{expanded && task.description && (
{expanded && (
<div
style={{
marginTop: "0.75rem",
paddingTop: "0.75rem",
borderTop: "1px solid #334155",
fontSize: "0.8rem",
color: "#94a3b8",
whiteSpace: "pre-wrap",
}}
>
{task.description.slice(0, 500)}
{task.description.length > 500 && "..."}
{editingField === "description" ? (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit(e);
// Allow Enter for newlines in textarea, use Ctrl+Enter to save
if (e.key === "Enter" && e.ctrlKey) saveEdit(e);
}}
onClick={(e) => e.stopPropagation()}
autoFocus
rows={5}
style={{
...inputStyle,
fontSize: "0.8rem",
resize: "vertical",
minHeight: "80px",
}}
placeholder="Enter description..."
/>
<div style={{ display: "flex", gap: "0.25rem", justifyContent: "flex-end" }}>
<span style={{ fontSize: "0.625rem", color: "#64748b", marginRight: "auto" }}>
Ctrl+Enter to save, Escape to cancel
</span>
<button onClick={saveEdit} style={{ ...selectStyle, backgroundColor: "#22c55e", padding: "0.25rem 0.5rem" }}>Save</button>
<button onClick={cancelEdit} style={{ ...selectStyle, backgroundColor: "#ef4444", padding: "0.25rem 0.5rem" }}>Cancel</button>
</div>
</div>
) : (
<div
onClick={(e) => startEditing("description", task.description || "", e)}
style={{
fontSize: "0.8rem",
color: "#94a3b8",
whiteSpace: "pre-wrap",
cursor: "text",
minHeight: "20px",
}}
title="Click to edit description"
>
{task.description ? (
<>
{task.description.slice(0, 500)}
{task.description.length > 500 && "..."}
</>
) : (
<span style={{ fontStyle: "italic", color: "#64748b" }}>Click to add description...</span>
)}
</div>
)}
</div>
)}

View File

@ -7,12 +7,15 @@ interface TaskCardProps {
onEdit: (task: Task) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
onDelete?: (taskId: string) => void;
onArchive?: (taskId: string) => void;
status?: string;
}
const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEnd, status }) => {
const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEnd, onDelete, onArchive, status }) => {
const [isDragging, setIsDragging] = React.useState(false);
const [showBranchTooltip, setShowBranchTooltip] = React.useState(false);
const [isHovering, setIsHovering] = React.useState(false);
// Check if task is from another branch (read-only)
const isFromOtherBranch = Boolean(task.branch);
@ -74,8 +77,54 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onEdit, onDragStart, onDragEn
return text.substring(0, maxLength).trim() + '...';
};
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onDelete && !isFromOtherBranch) {
onDelete(task.id);
}
};
const handleArchiveClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (onArchive && !isFromOtherBranch) {
onArchive(task.id);
}
};
return (
<div className="relative">
<div
className="relative"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
{/* Action buttons - shown on hover */}
{isHovering && !isFromOtherBranch && !isDragging && (
<div className="absolute -top-2 -right-2 z-10 flex gap-1">
{onArchive && (
<button
onClick={handleArchiveClick}
className="w-6 h-6 flex items-center justify-center bg-blue-500 hover:bg-blue-600 text-white rounded-full shadow-md transition-colors duration-150 cursor-pointer"
title="Archive task"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</button>
)}
{onDelete && (
<button
onClick={handleDeleteClick}
className="w-6 h-6 flex items-center justify-center bg-red-500 hover:bg-red-600 text-white rounded-full shadow-md transition-colors duration-150 cursor-pointer"
title="Delete task"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
)}
{/* Branch tooltip when trying to drag cross-branch task */}
{showBranchTooltip && isFromOtherBranch && (
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 z-50 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-md shadow-lg whitespace-nowrap">

View File

@ -13,6 +13,8 @@ interface TaskColumnProps {
onDragStart?: () => void;
onDragEnd?: () => void;
onCleanup?: () => void;
onDeleteTask?: (taskId: string) => void;
onArchiveTask?: (taskId: string) => void;
}
const TaskColumn: React.FC<TaskColumnProps> = ({
@ -24,7 +26,9 @@ const TaskColumn: React.FC<TaskColumnProps> = ({
dragSourceStatus,
onDragStart,
onDragEnd,
onCleanup
onCleanup,
onDeleteTask,
onArchiveTask
}) => {
const [isDragOver, setIsDragOver] = React.useState(false);
const [draggedTaskId, setDraggedTaskId] = React.useState<string | null>(null);
@ -176,6 +180,8 @@ const TaskColumn: React.FC<TaskColumnProps> = ({
setDropPosition(null);
onDragEnd?.();
}}
onDelete={onDeleteTask}
onArchive={onArchiveTask}
status={title}
/>

File diff suppressed because one or more lines are too long