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:
parent
6421377c86
commit
10ee1094c1
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue