feat: toast notifications, delete task, due date badges on cards, keyboard shortcuts
This commit is contained in:
@@ -7,6 +7,7 @@ import { ProjectsPage } from "./pages/ProjectsPage";
|
||||
import { TaskPage } from "./pages/TaskPage";
|
||||
import { AdminPage } from "./components/AdminPage";
|
||||
import { LoginPage } from "./components/LoginPage";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import { useSession } from "./lib/auth-client";
|
||||
|
||||
function AuthenticatedApp() {
|
||||
@@ -42,7 +43,11 @@ function App() {
|
||||
return <LoginPage onSuccess={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return <AuthenticatedApp />;
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AuthenticatedApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -123,6 +123,37 @@ export function TaskCard({
|
||||
{task.description && (
|
||||
<p className="text-xs sm:text-sm text-gray-500 line-clamp-1">{task.description}</p>
|
||||
)}
|
||||
|
||||
{/* Due date and subtask badges */}
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{task.dueDate && (() => {
|
||||
const due = new Date(task.dueDate);
|
||||
const diffMs = due.getTime() - Date.now();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
const isOverdue = diffMs < 0;
|
||||
const isDueSoon = diffDays <= 2 && !isOverdue;
|
||||
return (
|
||||
<span className={`text-[10px] sm:text-xs px-1.5 py-0.5 rounded-full font-medium inline-flex items-center gap-0.5 ${
|
||||
isOverdue ? "bg-red-100 text-red-700" : isDueSoon ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"
|
||||
}`}>
|
||||
📅 {isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{task.subtasks?.length > 0 && (() => {
|
||||
const done = task.subtasks.filter(s => s.completed).length;
|
||||
const total = task.subtasks.length;
|
||||
const pct = Math.round((done / total) * 100);
|
||||
return (
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 inline-flex items-center gap-1.5">
|
||||
<span className="inline-block w-12 h-1 bg-gray-200 rounded-full overflow-hidden">
|
||||
<span className={`block h-full rounded-full ${pct === 100 ? "bg-green-500" : "bg-amber-400"}`} style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
{done}/{total}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand chevron - always visible */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask } from "../lib/api";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
critical: "bg-red-500 text-white",
|
||||
@@ -246,6 +247,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [noteText, setNoteText] = useState("");
|
||||
const [addingNote, setAddingNote] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Draft state for editable fields
|
||||
const [draftTitle, setDraftTitle] = useState(task.title);
|
||||
@@ -263,6 +267,15 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
fetchProjects().then(setProjects).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut: Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !showDeleteConfirm) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose, showDeleteConfirm]);
|
||||
|
||||
// Reset drafts when task changes
|
||||
useEffect(() => {
|
||||
setDraftTitle(task.title);
|
||||
@@ -305,13 +318,32 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
if (draftDueDate !== currentDueDate) updates.dueDate = draftDueDate ? new Date(draftDueDate).toISOString() : null;
|
||||
await updateTask(task.id, updates, token);
|
||||
onTaskUpdated();
|
||||
toast("Changes saved", "success");
|
||||
} catch (e) {
|
||||
console.error("Failed to update:", e);
|
||||
toast("Failed to save changes", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!hasToken) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteTask(task.id, token);
|
||||
toast("Task deleted", "success");
|
||||
onClose();
|
||||
onTaskUpdated();
|
||||
} catch (e) {
|
||||
console.error("Failed to delete:", e);
|
||||
toast("Failed to delete task", "error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy single-field save available if needed
|
||||
const _handleFieldSave = async (field: string, value: string) => {
|
||||
if (!hasToken) return;
|
||||
@@ -802,7 +834,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
{hasToken && actions.length > 0 && (
|
||||
<div className="px-4 sm:px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Actions</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.next}
|
||||
@@ -815,6 +847,33 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1.5 rounded transition"
|
||||
title="Delete task"
|
||||
>
|
||||
🗑 Delete
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 animate-slide-up">
|
||||
<span className="text-xs text-red-700">Delete this task?</span>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="text-xs px-3 py-1 bg-red-600 text-white rounded font-medium hover:bg-red-700 transition disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "..." : "Yes, delete"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
67
frontend/src/components/Toast.tsx
Normal file
67
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "success" | "error" | "info";
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (message: string, type?: Toast["type"]) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((message: string, type: Toast["type"] = "info") => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const typeStyles: Record<Toast["type"], string> = {
|
||||
success: "bg-green-600 text-white",
|
||||
error: "bg-red-600 text-white",
|
||||
info: "bg-gray-800 text-white",
|
||||
};
|
||||
|
||||
const typeIcons: Record<Toast["type"], string> = {
|
||||
success: "✓",
|
||||
error: "✕",
|
||||
info: "ℹ",
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast: addToast }}>
|
||||
{children}
|
||||
{/* Toast container */}
|
||||
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 text-sm font-medium animate-slide-up min-w-[200px] max-w-sm ${typeStyles[t.type]}`}
|
||||
onClick={() => removeToast(t.id)}
|
||||
role="alert"
|
||||
>
|
||||
<span className="text-base shrink-0">{typeIcons[t.type]}</span>
|
||||
<span className="flex-1">{t.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||
import { TaskCard } from "../components/TaskCard";
|
||||
import { TaskDetailPanel } from "../components/TaskDetailPanel";
|
||||
import { CreateTaskModal } from "../components/CreateTaskModal";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { updateTask, reorderTasks, createTask } from "../lib/api";
|
||||
import type { TaskStatus } from "../lib/types";
|
||||
|
||||
@@ -15,6 +16,7 @@ export function QueuePage() {
|
||||
const [selectedTask, setSelectedTask] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filterPriority, setFilterPriority] = useState<string>("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let filtered = tasks;
|
||||
@@ -50,8 +52,9 @@ export function QueuePage() {
|
||||
try {
|
||||
await updateTask(id, { status });
|
||||
refresh();
|
||||
toast(`Task moved to ${status}`, "success");
|
||||
} catch (e) {
|
||||
alert("Failed to update task.");
|
||||
toast("Failed to update task", "error");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,8 +80,13 @@ export function QueuePage() {
|
||||
source?: string;
|
||||
priority?: string;
|
||||
}) => {
|
||||
try {
|
||||
await createTask(task);
|
||||
refresh();
|
||||
toast("Task created", "success");
|
||||
} catch (e) {
|
||||
toast("Failed to create task", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||
import type { Task, TaskStatus, Project } from "../lib/types";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask } from "../lib/api";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||
import { useToast } from "../components/Toast";
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: "bg-red-500 text-white",
|
||||
@@ -73,6 +74,10 @@ export function TaskPage() {
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
|
||||
const [addingSubtask, setAddingSubtask] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchTask = useCallback(async () => {
|
||||
if (!taskRef) return;
|
||||
@@ -106,13 +111,31 @@ export function TaskPage() {
|
||||
try {
|
||||
await updateTask(task.id, { status });
|
||||
fetchTask();
|
||||
toast(`Task moved to ${status}`, "success");
|
||||
} catch (e) {
|
||||
console.error("Failed to update status:", e);
|
||||
toast("Failed to update status", "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!task) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteTask(task.id);
|
||||
toast("Task deleted", "success");
|
||||
navigate("/queue");
|
||||
} catch (e) {
|
||||
console.error("Failed to delete:", e);
|
||||
toast("Failed to delete task", "error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-gray-400">
|
||||
@@ -478,6 +501,7 @@ export function TaskPage() {
|
||||
onClick={() => {
|
||||
const url = `${window.location.origin}/task/HQ-${task.taskNumber}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
toast("Link copied", "info");
|
||||
}}
|
||||
className="text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200 transition"
|
||||
>
|
||||
@@ -485,6 +509,38 @@ export function TaskPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Danger Zone</h3>
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full text-sm text-red-600 hover:text-red-700 border border-red-200 rounded-lg px-3 py-2 hover:bg-red-50 transition font-medium"
|
||||
>
|
||||
🗑 Delete Task
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2 animate-slide-up">
|
||||
<p className="text-xs text-red-700">This cannot be undone. Are you sure?</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="flex-1 text-sm px-3 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Yes, delete"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user