feat: dark mode support, markdown descriptions, inline editing on TaskPage
- Full dark mode across TaskPage (header, cards, sidebar, forms) - Task descriptions rendered as markdown (ReactMarkdown + remark-gfm) - Inline description editing with markdown preview - Inline title editing (click to edit) - Theme system (useTheme hook with light/dark/system toggle) - Dark mode classes across remaining components
This commit is contained in:
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
|||||||
import { DashboardLayout } from "./components/DashboardLayout";
|
import { DashboardLayout } from "./components/DashboardLayout";
|
||||||
import { LoginPage } from "./components/LoginPage";
|
import { LoginPage } from "./components/LoginPage";
|
||||||
import { ToastProvider } from "./components/Toast";
|
import { ToastProvider } from "./components/Toast";
|
||||||
|
import { ThemeProvider } from "./hooks/useTheme";
|
||||||
import { useSession } from "./lib/auth-client";
|
import { useSession } from "./lib/auth-client";
|
||||||
|
|
||||||
// Lazy-loaded pages for code splitting
|
// Lazy-loaded pages for code splitting
|
||||||
@@ -50,20 +51,26 @@ function App() {
|
|||||||
|
|
||||||
if (session.isPending) {
|
if (session.isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center">
|
||||||
<div className="text-gray-400">Loading...</div>
|
<div className="text-gray-400">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.data) {
|
if (!session.data) {
|
||||||
return <LoginPage onSuccess={() => window.location.reload()} />;
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<LoginPage onSuccess={() => window.location.reload()} />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AuthenticatedApp />
|
<AuthenticatedApp />
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
import { useCurrentUser } from "../hooks/useCurrentUser";
|
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||||
|
import { useTheme } from "../hooks/useTheme";
|
||||||
import { signOut } from "../lib/auth-client";
|
import { signOut } from "../lib/auth-client";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -13,8 +14,16 @@ const navItems = [
|
|||||||
|
|
||||||
export function DashboardLayout() {
|
export function DashboardLayout() {
|
||||||
const { user, isAdmin } = useCurrentUser();
|
const { user, isAdmin } = useCurrentUser();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const cycleTheme = () => {
|
||||||
|
const next = theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
|
||||||
|
setTheme(next);
|
||||||
|
};
|
||||||
|
const themeIcon = theme === "light" ? "☀️" : theme === "dark" ? "🌙" : "💻";
|
||||||
|
const themeLabel = theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System";
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -23,7 +32,7 @@ export function DashboardLayout() {
|
|||||||
const closeSidebar = () => setSidebarOpen(false);
|
const closeSidebar = () => setSidebarOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<header className="md:hidden fixed top-0 left-0 right-0 z-40 bg-gray-900 text-white flex items-center h-14 px-4 shadow-lg">
|
<header className="md:hidden fixed top-0 left-0 right-0 z-40 bg-gray-900 text-white flex items-center h-14 px-4 shadow-lg">
|
||||||
<button
|
<button
|
||||||
@@ -138,6 +147,13 @@ export function DashboardLayout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={cycleTheme}
|
||||||
|
className="w-full text-xs text-gray-500 hover:text-gray-300 px-3 py-1.5 rounded-lg hover:bg-gray-800 transition text-left flex items-center gap-2 mb-1"
|
||||||
|
title={`Theme: ${themeLabel}`}
|
||||||
|
>
|
||||||
|
<span>{themeIcon}</span> {themeLabel}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full text-xs text-gray-500 hover:text-red-400 px-3 py-1.5 rounded-lg hover:bg-gray-800 transition text-left"
|
className="w-full text-xs text-gray-500 hover:text-red-400 px-3 py-1.5 rounded-lg hover:bg-gray-800 transition text-left"
|
||||||
|
|||||||
@@ -17,45 +17,45 @@ const COLUMNS: KanbanColumn[] = [
|
|||||||
status: "active",
|
status: "active",
|
||||||
label: "Active",
|
label: "Active",
|
||||||
icon: "⚡",
|
icon: "⚡",
|
||||||
color: "text-amber-700",
|
color: "text-amber-700 dark:text-amber-400",
|
||||||
bgColor: "bg-amber-50",
|
bgColor: "bg-amber-50 dark:bg-amber-900/10",
|
||||||
borderColor: "border-amber-200",
|
borderColor: "border-amber-200 dark:border-amber-800",
|
||||||
dropColor: "bg-amber-100",
|
dropColor: "bg-amber-100 dark:bg-amber-900/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: "queued",
|
status: "queued",
|
||||||
label: "Queued",
|
label: "Queued",
|
||||||
icon: "📋",
|
icon: "📋",
|
||||||
color: "text-blue-700",
|
color: "text-blue-700 dark:text-blue-400",
|
||||||
bgColor: "bg-blue-50",
|
bgColor: "bg-blue-50 dark:bg-blue-900/10",
|
||||||
borderColor: "border-blue-200",
|
borderColor: "border-blue-200 dark:border-blue-800",
|
||||||
dropColor: "bg-blue-100",
|
dropColor: "bg-blue-100 dark:bg-blue-900/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: "blocked",
|
status: "blocked",
|
||||||
label: "Blocked",
|
label: "Blocked",
|
||||||
icon: "🚫",
|
icon: "🚫",
|
||||||
color: "text-red-700",
|
color: "text-red-700 dark:text-red-400",
|
||||||
bgColor: "bg-red-50",
|
bgColor: "bg-red-50 dark:bg-red-900/10",
|
||||||
borderColor: "border-red-200",
|
borderColor: "border-red-200 dark:border-red-800",
|
||||||
dropColor: "bg-red-100",
|
dropColor: "bg-red-100 dark:bg-red-900/20",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: "completed",
|
status: "completed",
|
||||||
label: "Done",
|
label: "Done",
|
||||||
icon: "✅",
|
icon: "✅",
|
||||||
color: "text-green-700",
|
color: "text-green-700 dark:text-green-400",
|
||||||
bgColor: "bg-green-50",
|
bgColor: "bg-green-50 dark:bg-green-900/10",
|
||||||
borderColor: "border-green-200",
|
borderColor: "border-green-200 dark:border-green-800",
|
||||||
dropColor: "bg-green-100",
|
dropColor: "bg-green-100 dark:bg-green-900/20",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<string, string> = {
|
const PRIORITY_COLORS: Record<string, string> = {
|
||||||
critical: "bg-red-100 text-red-700 border-red-200",
|
critical: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800",
|
||||||
high: "bg-orange-100 text-orange-700 border-orange-200",
|
high: "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800",
|
||||||
medium: "bg-blue-100 text-blue-700 border-blue-200",
|
medium: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800",
|
||||||
low: "bg-gray-100 text-gray-500 border-gray-200",
|
low: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface KanbanCardProps {
|
interface KanbanCardProps {
|
||||||
@@ -81,16 +81,15 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
|
|||||||
}}
|
}}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`bg-white rounded-lg border border-gray-200 p-3 cursor-grab active:cursor-grabbing shadow-sm hover:shadow-md transition-all ${
|
className={`bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-3 cursor-grab active:cursor-grabbing shadow-sm hover:shadow-md transition-all ${
|
||||||
isDragging ? "opacity-40 scale-95" : "opacity-100"
|
isDragging ? "opacity-40 scale-95" : "opacity-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header: number + priority */}
|
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<Link
|
<Link
|
||||||
to={`/task/HQ-${task.taskNumber}`}
|
to={`/task/HQ-${task.taskNumber}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="text-xs font-bold font-mono text-gray-500 hover:text-amber-600 transition"
|
className="text-xs font-bold font-mono text-gray-500 dark:text-gray-400 hover:text-amber-600 dark:hover:text-amber-400 transition"
|
||||||
>
|
>
|
||||||
HQ-{task.taskNumber}
|
HQ-{task.taskNumber}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -99,20 +98,18 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug mb-1.5 line-clamp-2">
|
||||||
<h4 className="text-sm font-medium text-gray-900 leading-snug mb-1.5 line-clamp-2">
|
|
||||||
{task.title}
|
{task.title}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* Meta: project, assignee, subtasks, due */}
|
|
||||||
<div className="flex flex-wrap gap-1.5 items-center">
|
<div className="flex flex-wrap gap-1.5 items-center">
|
||||||
{projectName && (
|
{projectName && (
|
||||||
<span className="text-[10px] text-sky-600 bg-sky-50 px-1.5 py-0.5 rounded-full">
|
<span className="text-[10px] text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-900/30 px-1.5 py-0.5 rounded-full">
|
||||||
📁 {projectName}
|
📁 {projectName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.assigneeName && (
|
{task.assigneeName && (
|
||||||
<span className="text-[10px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full">
|
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/30 px-1.5 py-0.5 rounded-full">
|
||||||
👤 {task.assigneeName}
|
👤 {task.assigneeName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -124,7 +121,7 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
|
|||||||
const label = isOverdue ? "overdue" : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`;
|
const label = isOverdue ? "overdue" : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`;
|
||||||
return (
|
return (
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||||
isOverdue ? "bg-red-100 text-red-600" : diffDays <= 2 ? "bg-amber-100 text-amber-600" : "bg-gray-100 text-gray-500"
|
isOverdue ? "bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400" : diffDays <= 2 ? "bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400" : "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||||
}`}>
|
}`}>
|
||||||
📅 {label}
|
📅 {label}
|
||||||
</span>
|
</span>
|
||||||
@@ -132,16 +129,15 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtask progress */}
|
|
||||||
{subtasksTotal > 0 && (
|
{subtasksTotal > 0 && (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<div className="flex-1 bg-gray-100 rounded-full h-1">
|
<div className="flex-1 bg-gray-100 dark:bg-gray-800 rounded-full h-1">
|
||||||
<div
|
<div
|
||||||
className="bg-green-500 h-1 rounded-full transition-all"
|
className="bg-green-500 h-1 rounded-full transition-all"
|
||||||
style={{ width: `${(subtasksDone / subtasksTotal) * 100}%` }}
|
style={{ width: `${(subtasksDone / subtasksTotal) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-gray-400">{subtasksDone}/{subtasksTotal}</span>
|
<span className="text-[10px] text-gray-400 dark:text-gray-500">{subtasksDone}/{subtasksTotal}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +198,6 @@ function KanbanColumnView({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
{/* Column header */}
|
|
||||||
<div className={`px-3 py-2.5 border-b ${column.borderColor} flex items-center justify-between`}>
|
<div className={`px-3 py-2.5 border-b ${column.borderColor} flex items-center justify-between`}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">{column.icon}</span>
|
<span className="text-sm">{column.icon}</span>
|
||||||
@@ -213,10 +208,9 @@ function KanbanColumnView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cards */}
|
|
||||||
<div className="flex-1 p-2 space-y-2 overflow-y-auto max-h-[calc(100vh-16rem)]">
|
<div className="flex-1 p-2 space-y-2 overflow-y-auto max-h-[calc(100vh-16rem)]">
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<div className="text-xs text-gray-400 text-center py-6 italic">
|
<div className="text-xs text-gray-400 dark:text-gray-500 text-center py-6 italic">
|
||||||
{dragOver ? "Drop here" : "No tasks"}
|
{dragOver ? "Drop here" : "No tasks"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -30,23 +30,23 @@ export function LoginPage({ onSuccess }: LoginPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<span className="text-5xl">🔨</span>
|
<span className="text-5xl">🔨</span>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mt-3">Hammer Queue</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-3">Hammer Queue</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Sign in to access the dashboard</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Sign in to access the dashboard</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-900 rounded-xl shadow-lg border border-gray-200 dark:border-gray-800 p-6 space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-sm">
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 rounded-lg p-3 text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -56,13 +56,13 @@ export function LoginPage({ onSuccess }: LoginPageProps) {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
|
className="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 dark:focus:ring-amber-600 focus:border-transparent placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -71,7 +71,7 @@ export function LoginPage({ onSuccess }: LoginPageProps) {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
|
className="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 dark:focus:ring-amber-600 focus:border-transparent placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +85,7 @@ export function LoginPage({ onSuccess }: LoginPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-400 mt-6">
|
<p className="text-center text-xs text-gray-400 dark:text-gray-500 mt-6">
|
||||||
Invite-only access · Contact admin for an account
|
Invite-only access · Contact admin for an account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ const priorityColors: Record<TaskPriority, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sourceColors: Record<string, string> = {
|
const sourceColors: Record<string, string> = {
|
||||||
donovan: "bg-purple-100 text-purple-800",
|
donovan: "bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300",
|
||||||
david: "bg-green-100 text-green-800",
|
david: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300",
|
||||||
hammer: "bg-yellow-100 text-yellow-800",
|
hammer: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300",
|
||||||
heartbeat: "bg-pink-100 text-pink-800",
|
heartbeat: "bg-pink-100 dark:bg-pink-900/30 text-pink-800 dark:text-pink-300",
|
||||||
cron: "bg-indigo-100 text-indigo-800",
|
cron: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-300",
|
||||||
other: "bg-gray-100 text-gray-800",
|
other: "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus }[]> = {
|
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus }[]> = {
|
||||||
@@ -76,8 +76,8 @@ export function TaskCard({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`rounded-xl border p-3 sm:p-4 transition-all cursor-pointer group ${
|
className={`rounded-xl border p-3 sm:p-4 transition-all cursor-pointer group ${
|
||||||
isActive
|
isActive
|
||||||
? "border-amber-300 bg-gradient-to-r from-amber-50 to-orange-50 shadow-lg shadow-amber-100/50 hover:shadow-xl hover:shadow-amber-200/50"
|
? "border-amber-300 dark:border-amber-700 bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 shadow-lg shadow-amber-100/50 dark:shadow-amber-900/20 hover:shadow-xl hover:shadow-amber-200/50 dark:hover:shadow-amber-900/30"
|
||||||
: "border-gray-200 bg-white shadow-sm hover:shadow-md hover:border-gray-300"
|
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Top row: title + expand chevron */}
|
{/* Top row: title + expand chevron */}
|
||||||
@@ -90,14 +90,14 @@ export function TaskCard({
|
|||||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<h3 className={`font-semibold text-sm sm:text-base leading-snug ${isActive ? "text-amber-900" : "text-gray-900"}`} style={{ wordBreak: "break-word" }}>
|
<h3 className={`font-semibold text-sm sm:text-base leading-snug ${isActive ? "text-amber-900 dark:text-amber-200" : "text-gray-900 dark:text-gray-100"}`} style={{ wordBreak: "break-word" }}>
|
||||||
{task.title}
|
{task.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2 mb-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 sm:gap-2 mb-1.5 flex-wrap">
|
||||||
<span
|
<span
|
||||||
className="text-[10px] sm:text-xs px-1.5 py-0.5 rounded font-mono font-bold bg-amber-100 text-amber-700 cursor-pointer hover:bg-amber-200 transition"
|
className="text-[10px] sm:text-xs px-1.5 py-0.5 rounded font-mono font-bold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-900/50 transition"
|
||||||
title={`Click to copy: ${displayId}`}
|
title={`Click to copy: ${displayId}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -112,30 +112,28 @@ export function TaskCard({
|
|||||||
<span className={`text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}>
|
<span className={`text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}>
|
||||||
{task.source}
|
{task.source}
|
||||||
</span>
|
</span>
|
||||||
{/* Project badge */}
|
|
||||||
{projectName && (
|
{projectName && (
|
||||||
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-sky-100 text-sky-700">
|
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-400">
|
||||||
📁 {projectName}
|
📁 {projectName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Assignee badge */}
|
|
||||||
{task.assigneeName && (
|
{task.assigneeName && (
|
||||||
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-emerald-100 text-emerald-700">
|
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400">
|
||||||
👤 {task.assigneeName}
|
👤 {task.assigneeName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] sm:text-xs text-gray-400">
|
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500">
|
||||||
{timeAgo(task.createdAt)}
|
{timeAgo(task.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
{noteCount > 0 && (
|
{noteCount > 0 && (
|
||||||
<span className="text-[10px] sm:text-xs text-gray-400 flex items-center gap-0.5">
|
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 flex items-center gap-0.5">
|
||||||
💬 {noteCount}
|
💬 {noteCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<p className="text-xs sm:text-sm text-gray-500 line-clamp-1">{task.description}</p>
|
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 line-clamp-1">{task.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Due date and subtask badges */}
|
{/* Due date and subtask badges */}
|
||||||
@@ -148,7 +146,7 @@ export function TaskCard({
|
|||||||
const isDueSoon = diffDays <= 2 && !isOverdue;
|
const isDueSoon = diffDays <= 2 && !isOverdue;
|
||||||
return (
|
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 ${
|
<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 ? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400" : isDueSoon ? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400" : "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||||
}`}>
|
}`}>
|
||||||
📅 {isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`}
|
📅 {isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`}
|
||||||
</span>
|
</span>
|
||||||
@@ -159,8 +157,8 @@ export function TaskCard({
|
|||||||
const total = task.subtasks.length;
|
const total = task.subtasks.length;
|
||||||
const pct = Math.round((done / total) * 100);
|
const pct = Math.round((done / total) * 100);
|
||||||
return (
|
return (
|
||||||
<span className="text-[10px] sm:text-xs text-gray-400 inline-flex items-center gap-1.5">
|
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 inline-flex items-center gap-1.5">
|
||||||
<span className="inline-block w-12 h-1 bg-gray-200 rounded-full overflow-hidden">
|
<span className="inline-block w-12 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
<span className={`block h-full rounded-full ${pct === 100 ? "bg-green-500" : "bg-amber-400"}`} style={{ width: `${pct}%` }} />
|
<span className={`block h-full rounded-full ${pct === 100 ? "bg-green-500" : "bg-amber-400"}`} style={{ width: `${pct}%` }} />
|
||||||
</span>
|
</span>
|
||||||
{done}/{total}
|
{done}/{total}
|
||||||
@@ -170,23 +168,22 @@ export function TaskCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expand chevron - always visible */}
|
{/* Expand chevron */}
|
||||||
<div className="shrink-0 mt-1 text-gray-300 group-hover:text-gray-500 transition">
|
<div className="shrink-0 mt-1 text-gray-300 dark:text-gray-600 group-hover:text-gray-500 dark:group-hover:text-gray-400 transition">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons row - hidden on mobile, shown on sm+ */}
|
{/* Action buttons row */}
|
||||||
<div className="hidden sm:flex items-center gap-1 mt-2 pt-2 border-t border-gray-100" onClick={(e) => e.stopPropagation()}>
|
<div className="hidden sm:flex items-center gap-1 mt-2 pt-2 border-t border-gray-100 dark:border-gray-800" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Reorder buttons for queued tasks */}
|
|
||||||
{task.status === "queued" && (
|
{task.status === "queued" && (
|
||||||
<div className="flex gap-1 mr-1">
|
<div className="flex gap-1 mr-1">
|
||||||
<button
|
<button
|
||||||
onClick={onMoveUp}
|
onClick={onMoveUp}
|
||||||
disabled={isFirst}
|
disabled={isFirst}
|
||||||
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
|
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed transition"
|
||||||
title="Move up"
|
title="Move up"
|
||||||
>
|
>
|
||||||
↑
|
↑
|
||||||
@@ -194,7 +191,7 @@ export function TaskCard({
|
|||||||
<button
|
<button
|
||||||
onClick={onMoveDown}
|
onClick={onMoveDown}
|
||||||
disabled={isLast}
|
disabled={isLast}
|
||||||
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
|
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed transition"
|
||||||
title="Move down"
|
title="Move down"
|
||||||
>
|
>
|
||||||
↓
|
↓
|
||||||
@@ -202,12 +199,11 @@ export function TaskCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick status actions */}
|
|
||||||
{actions.slice(0, 2).map((action) => (
|
{actions.slice(0, 2).map((action) => (
|
||||||
<button
|
<button
|
||||||
key={action.next}
|
key={action.next}
|
||||||
onClick={() => onStatusChange(task.id, action.next)}
|
onClick={() => onStatusChange(task.id, action.next)}
|
||||||
className="text-xs px-2.5 py-1.5 rounded-lg border border-gray-200 hover:bg-gray-50 whitespace-nowrap text-gray-600 hover:text-gray-800 transition font-medium"
|
className="text-xs px-2.5 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 whitespace-nowrap text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition font-medium"
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ const priorityIcons: Record<TaskPriority, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusColors: Record<TaskStatus, string> = {
|
const statusColors: Record<TaskStatus, string> = {
|
||||||
active: "bg-amber-100 text-amber-800 border-amber-300",
|
active: "bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 border-amber-300 dark:border-amber-700",
|
||||||
queued: "bg-blue-100 text-blue-800 border-blue-300",
|
queued: "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-300 dark:border-blue-700",
|
||||||
blocked: "bg-red-100 text-red-800 border-red-300",
|
blocked: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-300 dark:border-red-700",
|
||||||
completed: "bg-green-100 text-green-800 border-green-300",
|
completed: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-300 dark:border-green-700",
|
||||||
cancelled: "bg-gray-100 text-gray-600 border-gray-300",
|
cancelled: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusIcons: Record<TaskStatus, string> = {
|
const statusIcons: Record<TaskStatus, string> = {
|
||||||
@@ -34,33 +34,33 @@ const statusIcons: Record<TaskStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sourceColors: Record<string, string> = {
|
const sourceColors: Record<string, string> = {
|
||||||
donovan: "bg-purple-100 text-purple-800",
|
donovan: "bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300",
|
||||||
david: "bg-green-100 text-green-800",
|
david: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300",
|
||||||
hammer: "bg-yellow-100 text-yellow-800",
|
hammer: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300",
|
||||||
heartbeat: "bg-pink-100 text-pink-800",
|
heartbeat: "bg-pink-100 dark:bg-pink-900/30 text-pink-800 dark:text-pink-300",
|
||||||
cron: "bg-indigo-100 text-indigo-800",
|
cron: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-300",
|
||||||
other: "bg-gray-100 text-gray-800",
|
other: "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus; color: string }[]> = {
|
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus; color: string }[]> = {
|
||||||
active: [
|
active: [
|
||||||
{ label: "⏸ Pause", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" },
|
{ label: "⏸ Pause", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30" },
|
||||||
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" },
|
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800 hover:bg-red-100 dark:hover:bg-red-900/30" },
|
||||||
{ label: "✅ Complete", next: "completed", color: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100" },
|
{ label: "✅ Complete", next: "completed", color: "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/30" },
|
||||||
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" },
|
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700" },
|
||||||
],
|
],
|
||||||
queued: [
|
queued: [
|
||||||
{ label: "▶ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" },
|
{ label: "▶ Activate", next: "active", color: "bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-100 dark:hover:bg-amber-900/30" },
|
||||||
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" },
|
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800 hover:bg-red-100 dark:hover:bg-red-900/30" },
|
||||||
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" },
|
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700" },
|
||||||
],
|
],
|
||||||
blocked: [
|
blocked: [
|
||||||
{ label: "▶ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" },
|
{ label: "▶ Activate", next: "active", color: "bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-800 hover:bg-amber-100 dark:hover:bg-amber-900/30" },
|
||||||
{ label: "📋 Queue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" },
|
{ label: "📋 Queue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30" },
|
||||||
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100" },
|
{ label: "❌ Cancel", next: "cancelled", color: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700" },
|
||||||
],
|
],
|
||||||
completed: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }],
|
completed: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30" }],
|
||||||
cancelled: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }],
|
cancelled: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900/30" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const allPriorities: TaskPriority[] = ["critical", "high", "medium", "low"];
|
const allPriorities: TaskPriority[] = ["critical", "high", "medium", "low"];
|
||||||
@@ -119,7 +119,7 @@ function ElapsedTimer({ since }: { since: string }) {
|
|||||||
}, [since]);
|
}, [since]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="font-mono text-amber-700 font-semibold">{elapsed}</span>
|
<span className="font-mono text-amber-700 dark:text-amber-400 font-semibold">{elapsed}</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,11 +149,11 @@ function EditableText({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
className={`cursor-pointer rounded-md px-2 py-1 -mx-2 -my-1 hover:bg-gray-100 transition group ${className}`}
|
className={`cursor-pointer rounded-md px-2 py-1 -mx-2 -my-1 hover:bg-gray-100 dark:hover:bg-gray-800 transition group ${className}`}
|
||||||
title="Click to edit"
|
title="Click to edit"
|
||||||
>
|
>
|
||||||
{value || <span className="text-gray-400 italic">{placeholder}</span>}
|
{value || <span className="text-gray-400 dark:text-gray-500 italic">{placeholder}</span>}
|
||||||
<span className="text-gray-300 opacity-0 group-hover:opacity-100 ml-1 text-xs">✏️</span>
|
<span className="text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100 ml-1 text-xs">✏️</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ function EditableText({
|
|||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onBlur={stopEditing}
|
onBlur={stopEditing}
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") stopEditing(); }}
|
onKeyDown={(e) => { if (e.key === "Escape") stopEditing(); }}
|
||||||
className={`w-full rounded-md border border-blue-300 bg-white px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 resize-y min-h-[60px] ${className}`}
|
className={`w-full rounded-md border border-blue-300 dark:border-blue-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 resize-y min-h-[60px] ${className}`}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -182,7 +182,7 @@ function EditableText({
|
|||||||
if (e.key === "Enter") stopEditing();
|
if (e.key === "Enter") stopEditing();
|
||||||
if (e.key === "Escape") stopEditing();
|
if (e.key === "Escape") stopEditing();
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-md border border-blue-300 bg-white px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${className}`}
|
className={`w-full rounded-md border border-blue-300 dark:border-blue-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-2 py-1 -mx-2 -my-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 ${className}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -199,30 +199,30 @@ function CopyableId({ id, taskNumber }: { id: string; taskNumber?: number }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 sm:px-6 py-3 border-t border-gray-100 bg-gray-50 flex items-center gap-2">
|
<div className="px-4 sm:px-6 py-3 border-t border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 flex items-center gap-2">
|
||||||
{displayId && (
|
{displayId && (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs font-bold text-amber-700 bg-amber-100 px-2 py-0.5 rounded font-mono">{displayId}</span>
|
<span className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded font-mono">{displayId}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(displayId, "ref")}
|
onClick={() => handleCopy(displayId, "ref")}
|
||||||
className={`text-xs px-2 py-0.5 rounded-md border transition font-medium shrink-0 ${
|
className={`text-xs px-2 py-0.5 rounded-md border transition font-medium shrink-0 ${
|
||||||
copied === "ref"
|
copied === "ref"
|
||||||
? "bg-green-50 text-green-600 border-green-200"
|
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800"
|
||||||
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-100"
|
: "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{copied === "ref" ? "✓" : "📋"}
|
{copied === "ref" ? "✓" : "📋"}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-300 hidden sm:inline">|</span>
|
<span className="text-gray-300 dark:text-gray-600 hidden sm:inline">|</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<code className="text-[10px] sm:text-xs text-gray-400 font-mono flex-1 truncate select-all hidden sm:block">{id}</code>
|
<code className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 font-mono flex-1 truncate select-all hidden sm:block">{id}</code>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(id, "uuid")}
|
onClick={() => handleCopy(id, "uuid")}
|
||||||
className={`text-xs px-2.5 py-1 rounded-md border transition font-medium shrink-0 ${
|
className={`text-xs px-2.5 py-1 rounded-md border transition font-medium shrink-0 ${
|
||||||
copied === "uuid"
|
copied === "uuid"
|
||||||
? "bg-green-50 text-green-600 border-green-200"
|
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800"
|
||||||
: "bg-white text-gray-500 border-gray-200 hover:bg-gray-100 hover:text-gray-700"
|
: "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200"
|
||||||
}`}
|
}`}
|
||||||
title="Copy UUID"
|
title="Copy UUID"
|
||||||
>
|
>
|
||||||
@@ -251,7 +251,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Draft state for editable fields
|
|
||||||
const [draftTitle, setDraftTitle] = useState(task.title);
|
const [draftTitle, setDraftTitle] = useState(task.title);
|
||||||
const [draftDescription, setDraftDescription] = useState(task.description || "");
|
const [draftDescription, setDraftDescription] = useState(task.description || "");
|
||||||
const [draftPriority, setDraftPriority] = useState(task.priority);
|
const [draftPriority, setDraftPriority] = useState(task.priority);
|
||||||
@@ -263,12 +262,10 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
|
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
|
||||||
const [addingSubtask, setAddingSubtask] = useState(false);
|
const [addingSubtask, setAddingSubtask] = useState(false);
|
||||||
|
|
||||||
// Fetch projects for the selector
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects().then(setProjects).catch(() => {});
|
fetchProjects().then(setProjects).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keyboard shortcut: Escape to close
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && !showDeleteConfirm) onClose();
|
if (e.key === "Escape" && !showDeleteConfirm) onClose();
|
||||||
@@ -277,7 +274,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [onClose, showDeleteConfirm]);
|
}, [onClose, showDeleteConfirm]);
|
||||||
|
|
||||||
// Reset drafts when task changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraftTitle(task.title);
|
setDraftTitle(task.title);
|
||||||
setDraftDescription(task.description || "");
|
setDraftDescription(task.description || "");
|
||||||
@@ -288,7 +284,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
setDraftAssigneeName(task.assigneeName || "");
|
setDraftAssigneeName(task.assigneeName || "");
|
||||||
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName]);
|
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName]);
|
||||||
|
|
||||||
// Detect if any field has been modified
|
|
||||||
const currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "";
|
const currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "";
|
||||||
const isDirty =
|
const isDirty =
|
||||||
draftTitle !== task.title ||
|
draftTitle !== task.title ||
|
||||||
@@ -349,7 +344,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Legacy single-field save available if needed
|
|
||||||
const _handleFieldSave = async (field: string, value: string) => {
|
const _handleFieldSave = async (field: string, value: string) => {
|
||||||
if (!hasToken) return;
|
if (!hasToken) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -362,26 +356,23 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void _handleFieldSave; // suppress unused warning
|
void _handleFieldSave;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40 transition-opacity"
|
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40 transition-opacity"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Panel */}
|
<div className="fixed inset-0 sm:inset-y-0 sm:left-auto sm:right-0 w-full sm:max-w-lg bg-white dark:bg-gray-900 shadow-2xl z-50 flex flex-col animate-slide-in-right">
|
||||||
<div className="fixed inset-0 sm:inset-y-0 sm:left-auto sm:right-0 w-full sm:max-w-lg bg-white shadow-2xl z-50 flex flex-col animate-slide-in-right">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`px-4 sm:px-6 py-3 sm:py-4 border-b ${isActive ? "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" : "bg-gray-50 border-gray-200"}`}>
|
<div className={`px-4 sm:px-6 py-3 sm:py-4 border-b ${isActive ? "bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border-amber-200 dark:border-amber-800" : "bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700"}`}>
|
||||||
<div className="flex items-start justify-between gap-2 sm:gap-3">
|
<div className="flex items-start justify-between gap-2 sm:gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Back button on mobile */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="sm:hidden flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-2 -ml-1 px-1 py-0.5 rounded transition"
|
className="sm:hidden flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-2 -ml-1 px-1 py-0.5 rounded transition"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
@@ -396,29 +387,28 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.taskNumber && (
|
{task.taskNumber && (
|
||||||
<span className="text-xs font-bold text-amber-700 bg-amber-100 px-2 py-0.5 rounded font-mono">
|
<span className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded font-mono">
|
||||||
HQ-{task.taskNumber}
|
HQ-{task.taskNumber}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={`text-xs px-2.5 py-1 rounded-full font-semibold border ${statusColors[task.status]}`}>
|
<span className={`text-xs px-2.5 py-1 rounded-full font-semibold border ${statusColors[task.status]}`}>
|
||||||
{statusIcons[task.status]} {task.status.toUpperCase()}
|
{statusIcons[task.status]} {task.status.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{saving && <span className="text-xs text-blue-500 animate-pulse">Saving...</span>}
|
{saving && <span className="text-xs text-blue-500 dark:text-blue-400 animate-pulse">Saving...</span>}
|
||||||
</div>
|
</div>
|
||||||
{hasToken ? (
|
{hasToken ? (
|
||||||
<EditableText
|
<EditableText
|
||||||
value={draftTitle}
|
value={draftTitle}
|
||||||
onChange={setDraftTitle}
|
onChange={setDraftTitle}
|
||||||
className="text-base sm:text-lg font-bold text-gray-900 leading-snug"
|
className="text-base sm:text-lg font-bold text-gray-900 dark:text-gray-100 leading-snug"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h2 className="text-base sm:text-lg font-bold text-gray-900 leading-snug">{task.title}</h2>
|
<h2 className="text-base sm:text-lg font-bold text-gray-900 dark:text-gray-100 leading-snug">{task.title}</h2>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Close X button - hidden on mobile (use Back instead) */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="hidden sm:block p-2 -mr-2 -mt-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition"
|
className="hidden sm:block p-2 -mr-2 -mt-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition"
|
||||||
title="Close"
|
title="Close"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
@@ -431,10 +421,10 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{/* Priority & Source */}
|
{/* Priority & Source */}
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Priority</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Priority</h3>
|
||||||
{hasToken ? (
|
{hasToken ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{allPriorities.map((p) => (
|
{allPriorities.map((p) => (
|
||||||
@@ -444,7 +434,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
|
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
|
||||||
p === draftPriority
|
p === draftPriority
|
||||||
? priorityColors[p]
|
? priorityColors[p]
|
||||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{priorityIcons[p]} {p}
|
{priorityIcons[p]} {p}
|
||||||
@@ -458,7 +448,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Source</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Source</h3>
|
||||||
{hasToken ? (
|
{hasToken ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{allSources.map((s) => (
|
{allSources.map((s) => (
|
||||||
@@ -468,7 +458,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
|
className={`text-xs px-2.5 py-1 rounded-full font-medium transition ${
|
||||||
s === draftSource
|
s === draftSource
|
||||||
? sourceColors[s] || sourceColors.other
|
? sourceColors[s] || sourceColors.other
|
||||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
@@ -486,13 +476,13 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
|
|
||||||
{/* Project */}
|
{/* Project */}
|
||||||
{(hasToken || task.projectId) && (
|
{(hasToken || task.projectId) && (
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Project</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Project</h3>
|
||||||
{hasToken ? (
|
{hasToken ? (
|
||||||
<select
|
<select
|
||||||
value={draftProjectId}
|
value={draftProjectId}
|
||||||
onChange={(e) => setDraftProjectId(e.target.value)}
|
onChange={(e) => setDraftProjectId(e.target.value)}
|
||||||
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white w-full max-w-xs"
|
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 w-full max-w-xs"
|
||||||
>
|
>
|
||||||
<option value="">No project</option>
|
<option value="">No project</option>
|
||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
@@ -500,7 +490,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{projects.find((p) => p.id === task.projectId)?.name || task.projectId || "None"}
|
{projects.find((p) => p.id === task.projectId)?.name || task.projectId || "None"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -508,8 +498,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Assignee */}
|
{/* Assignee */}
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Assignee</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Assignee</h3>
|
||||||
{hasToken ? (
|
{hasToken ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
@@ -520,7 +510,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
className={`text-xs px-2.5 py-1.5 rounded-lg font-medium transition border ${
|
className={`text-xs px-2.5 py-1.5 rounded-lg font-medium transition border ${
|
||||||
draftAssigneeName === name
|
draftAssigneeName === name
|
||||||
? "bg-emerald-500 text-white border-emerald-500"
|
? "bg-emerald-500 text-white border-emerald-500"
|
||||||
: "bg-white text-gray-600 border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
👤 {name}
|
👤 {name}
|
||||||
@@ -528,39 +518,39 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{draftAssigneeName && !["Hammer", "Donovan", "David"].includes(draftAssigneeName) && (
|
{draftAssigneeName && !["Hammer", "Donovan", "David"].includes(draftAssigneeName) && (
|
||||||
<span className="text-xs px-2 py-1 bg-emerald-100 text-emerald-700 rounded-full font-medium">
|
<span className="text-xs px-2 py-1 bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 rounded-full font-medium">
|
||||||
👤 {draftAssigneeName}
|
👤 {draftAssigneeName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{task.assigneeName ? (
|
{task.assigneeName ? (
|
||||||
<span className="text-xs px-2 py-1 bg-emerald-100 text-emerald-700 rounded-full font-medium">
|
<span className="text-xs px-2 py-1 bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 rounded-full font-medium">
|
||||||
👤 {task.assigneeName}
|
👤 {task.assigneeName}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400 italic">Unassigned</span>
|
<span className="text-gray-400 dark:text-gray-500 italic">Unassigned</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Due Date */}
|
{/* Due Date */}
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Due Date</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Due Date</h3>
|
||||||
{hasToken ? (
|
{hasToken ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={draftDueDate}
|
value={draftDueDate}
|
||||||
onChange={(e) => setDraftDueDate(e.target.value)}
|
onChange={(e) => setDraftDueDate(e.target.value)}
|
||||||
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white"
|
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
{draftDueDate && (
|
{draftDueDate && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDraftDueDate("")}
|
onClick={() => setDraftDueDate("")}
|
||||||
className="text-xs text-gray-400 hover:text-red-500 transition"
|
className="text-xs text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition"
|
||||||
title="Clear due date"
|
title="Clear due date"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -575,7 +565,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
const isDueSoon = diffDays <= 2 && !isOverdue;
|
const isDueSoon = diffDays <= 2 && !isOverdue;
|
||||||
return (
|
return (
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||||
isOverdue ? "bg-red-100 text-red-700" : isDueSoon ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-600"
|
isOverdue ? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400" : isDueSoon ? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400" : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||||
}`}>
|
}`}>
|
||||||
{isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "Due today" : diffDays === 1 ? "Due tomorrow" : `${diffDays}d left`}
|
{isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "Due today" : diffDays === 1 ? "Due tomorrow" : `${diffDays}d left`}
|
||||||
</span>
|
</span>
|
||||||
@@ -583,53 +573,53 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
{task.dueDate ? new Date(task.dueDate).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }) : <span className="text-gray-400 italic">No due date</span>}
|
{task.dueDate ? new Date(task.dueDate).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }) : <span className="text-gray-400 dark:text-gray-500 italic">No due date</span>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||||
{hasToken ? (
|
{hasToken ? (
|
||||||
<EditableText
|
<EditableText
|
||||||
value={draftDescription}
|
value={draftDescription}
|
||||||
onChange={setDraftDescription}
|
onChange={setDraftDescription}
|
||||||
multiline
|
multiline
|
||||||
className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap"
|
className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap"
|
||||||
placeholder="Add a description..."
|
placeholder="Add a description..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">
|
||||||
{task.description || <span className="text-gray-400 italic">No description</span>}
|
{task.description || <span className="text-gray-400 dark:text-gray-500 italic">No description</span>}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time Info */}
|
{/* Time Info */}
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Timeline</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
|
||||||
<span className="text-gray-500">Created</span>
|
<span className="text-gray-500 dark:text-gray-400">Created</span>
|
||||||
<span className="text-gray-700 font-medium text-xs sm:text-sm">{formatDate(task.createdAt)} <span className="text-gray-400 text-xs">({timeAgo(task.createdAt)})</span></span>
|
<span className="text-gray-700 dark:text-gray-300 font-medium text-xs sm:text-sm">{formatDate(task.createdAt)} <span className="text-gray-400 dark:text-gray-500 text-xs">({timeAgo(task.createdAt)})</span></span>
|
||||||
</div>
|
</div>
|
||||||
{task.updatedAt !== task.createdAt && (
|
{task.updatedAt !== task.createdAt && (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
|
||||||
<span className="text-gray-500">Updated</span>
|
<span className="text-gray-500 dark:text-gray-400">Updated</span>
|
||||||
<span className="text-gray-700 font-medium text-xs sm:text-sm">{formatDate(task.updatedAt)} <span className="text-gray-400 text-xs">({timeAgo(task.updatedAt)})</span></span>
|
<span className="text-gray-700 dark:text-gray-300 font-medium text-xs sm:text-sm">{formatDate(task.updatedAt)} <span className="text-gray-400 dark:text-gray-500 text-xs">({timeAgo(task.updatedAt)})</span></span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{task.completedAt && (
|
{task.completedAt && (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm gap-0.5">
|
||||||
<span className="text-gray-500">Completed</span>
|
<span className="text-gray-500 dark:text-gray-400">Completed</span>
|
||||||
<span className="text-gray-700 font-medium text-xs sm:text-sm">{formatDate(task.completedAt)}</span>
|
<span className="text-gray-700 dark:text-gray-300 font-medium text-xs sm:text-sm">{formatDate(task.completedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="flex items-center justify-between text-sm mt-1 pt-2 border-t border-amber-100">
|
<div className="flex items-center justify-between text-sm mt-1 pt-2 border-t border-amber-100 dark:border-amber-900/30">
|
||||||
<span className="text-amber-600 font-medium">⏱ Running for</span>
|
<span className="text-amber-600 dark:text-amber-400 font-medium">⏱ Running for</span>
|
||||||
<ElapsedTimer since={task.updatedAt} />
|
<ElapsedTimer since={task.updatedAt} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -637,19 +627,18 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtasks */}
|
{/* Subtasks */}
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||||
Subtasks {task.subtasks?.length > 0 && (
|
Subtasks {task.subtasks?.length > 0 && (
|
||||||
<span className="text-gray-300 ml-1">
|
<span className="text-gray-300 dark:text-gray-600 ml-1">
|
||||||
({task.subtasks.filter(s => s.completed).length}/{task.subtasks.length})
|
({task.subtasks.filter(s => s.completed).length}/{task.subtasks.length})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Subtask progress bar */}
|
|
||||||
{task.subtasks?.length > 0 && (
|
{task.subtasks?.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
className="bg-green-500 h-1.5 rounded-full transition-all duration-300"
|
className="bg-green-500 h-1.5 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${(task.subtasks.filter(s => s.completed).length / task.subtasks.length) * 100}%` }}
|
style={{ width: `${(task.subtasks.filter(s => s.completed).length / task.subtasks.length) * 100}%` }}
|
||||||
@@ -658,7 +647,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Subtask list */}
|
|
||||||
{task.subtasks?.length > 0 && (
|
{task.subtasks?.length > 0 && (
|
||||||
<div className="space-y-1 mb-3">
|
<div className="space-y-1 mb-3">
|
||||||
{task.subtasks.map((subtask) => (
|
{task.subtasks.map((subtask) => (
|
||||||
@@ -675,7 +663,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition ${
|
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition ${
|
||||||
subtask.completed
|
subtask.completed
|
||||||
? "bg-green-500 border-green-500 text-white"
|
? "bg-green-500 border-green-500 text-white"
|
||||||
: "border-gray-300 hover:border-amber-400"
|
: "border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{subtask.completed && (
|
{subtask.completed && (
|
||||||
@@ -684,7 +672,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span className={`text-sm flex-1 ${subtask.completed ? "line-through text-gray-400" : "text-gray-700"}`}>
|
<span className={`text-sm flex-1 ${subtask.completed ? "line-through text-gray-400 dark:text-gray-500" : "text-gray-700 dark:text-gray-300"}`}>
|
||||||
{subtask.title}
|
{subtask.title}
|
||||||
</span>
|
</span>
|
||||||
{hasToken && (
|
{hasToken && (
|
||||||
@@ -697,7 +685,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
console.error("Failed to delete subtask:", e);
|
console.error("Failed to delete subtask:", e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition p-0.5"
|
className="opacity-0 group-hover:opacity-100 text-gray-300 dark:text-gray-600 hover:text-red-400 dark:hover:text-red-400 transition p-0.5"
|
||||||
title="Remove subtask"
|
title="Remove subtask"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -710,7 +698,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add subtask input */}
|
|
||||||
{hasToken && (
|
{hasToken && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -718,7 +705,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
value={newSubtaskTitle}
|
value={newSubtaskTitle}
|
||||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||||
placeholder="Add a subtask..."
|
placeholder="Add a subtask..."
|
||||||
className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300"
|
className="flex-1 text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === "Enter" && newSubtaskTitle.trim()) {
|
if (e.key === "Enter" && newSubtaskTitle.trim()) {
|
||||||
setAddingSubtask(true);
|
setAddingSubtask(true);
|
||||||
@@ -760,13 +747,12 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
|
|
||||||
{/* Progress Notes */}
|
{/* Progress Notes */}
|
||||||
<div className="px-4 sm:px-6 py-4">
|
<div className="px-4 sm:px-6 py-4">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||||
Progress Notes {task.progressNotes?.length > 0 && (
|
Progress Notes {task.progressNotes?.length > 0 && (
|
||||||
<span className="text-gray-300 ml-1">({task.progressNotes.length})</span>
|
<span className="text-gray-300 dark:text-gray-600 ml-1">({task.progressNotes.length})</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Add note input */}
|
|
||||||
{hasToken && (
|
{hasToken && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -775,7 +761,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
onChange={(e) => setNoteText(e.target.value)}
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
placeholder="Add a progress note..."
|
placeholder="Add a progress note..."
|
||||||
rows={2}
|
rows={2}
|
||||||
className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300 resize-y min-h-[40px] max-h-32"
|
className="flex-1 text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-y min-h-[40px] max-h-32 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -810,12 +796,12 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
{addingNote ? "..." : "Add"}
|
{addingNote ? "..." : "Add"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-gray-400 mt-1">⌘+Enter to submit</p>
|
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">⌘+Enter to submit</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!task.progressNotes || task.progressNotes.length === 0 ? (
|
{!task.progressNotes || task.progressNotes.length === 0 ? (
|
||||||
<div className="text-sm text-gray-400 italic py-4 text-center border-2 border-dashed border-gray-100 rounded-lg">
|
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-4 text-center border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-lg">
|
||||||
No progress notes yet
|
No progress notes yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -825,24 +811,21 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
.reverse()
|
.reverse()
|
||||||
.map((note, i) => (
|
.map((note, i) => (
|
||||||
<div key={i} className="relative pl-6 pb-4 last:pb-0 group">
|
<div key={i} className="relative pl-6 pb-4 last:pb-0 group">
|
||||||
{/* Timeline line */}
|
|
||||||
{i < task.progressNotes.length - 1 && (
|
{i < task.progressNotes.length - 1 && (
|
||||||
<div className="absolute left-[9px] top-3 bottom-0 w-0.5 bg-gray-200 group-last:hidden" />
|
<div className="absolute left-[9px] top-3 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-last:hidden" />
|
||||||
)}
|
)}
|
||||||
{/* Timeline dot */}
|
|
||||||
<div className={`absolute left-0 top-1 w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center ${
|
<div className={`absolute left-0 top-1 w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center ${
|
||||||
i === 0 && isActive
|
i === 0 && isActive
|
||||||
? "border-amber-400 bg-amber-50"
|
? "border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/30"
|
||||||
: "border-gray-300 bg-white"
|
: "border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900"
|
||||||
}`}>
|
}`}>
|
||||||
<div className={`w-2 h-2 rounded-full ${
|
<div className={`w-2 h-2 rounded-full ${
|
||||||
i === 0 && isActive ? "bg-amber-500" : "bg-gray-300"
|
i === 0 && isActive ? "bg-amber-500" : "bg-gray-300 dark:bg-gray-600"
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700 leading-relaxed">{note.note}</p>
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{note.note}</p>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{formatTimestamp(note.timestamp)} · {timeAgo(note.timestamp)}</p>
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{formatTimestamp(note.timestamp)} · {timeAgo(note.timestamp)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -853,20 +836,20 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
|
|
||||||
{/* Save / Cancel Bar */}
|
{/* Save / Cancel Bar */}
|
||||||
{hasToken && isDirty && (
|
{hasToken && isDirty && (
|
||||||
<div className="px-4 sm:px-6 py-3 border-t border-blue-200 bg-blue-50 flex items-center justify-between animate-slide-up">
|
<div className="px-4 sm:px-6 py-3 border-t border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 flex items-center justify-between animate-slide-up">
|
||||||
<span className="text-sm text-blue-700 font-medium">Unsaved changes</span>
|
<span className="text-sm text-blue-700 dark:text-blue-300 font-medium">Unsaved changes</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="text-sm px-4 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 font-medium hover:bg-gray-50 transition disabled:opacity-50"
|
className="text-sm px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="text-sm px-4 py-2 rounded-lg border border-blue-400 bg-blue-600 text-white font-medium hover:bg-blue-700 transition disabled:opacity-50"
|
className="text-sm px-4 py-2 rounded-lg border border-blue-400 dark:border-blue-600 bg-blue-600 text-white font-medium hover:bg-blue-700 transition disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Save"}
|
{saving ? "Saving..." : "Save"}
|
||||||
</button>
|
</button>
|
||||||
@@ -876,8 +859,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
|
|
||||||
{/* Actions Footer */}
|
{/* Actions Footer */}
|
||||||
{hasToken && actions.length > 0 && (
|
{hasToken && actions.length > 0 && (
|
||||||
<div className="px-4 sm:px-6 py-4 border-t border-gray-200 bg-gray-50">
|
<div className="px-4 sm:px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Actions</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
<button
|
<button
|
||||||
@@ -895,14 +878,14 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
{!showDeleteConfirm ? (
|
{!showDeleteConfirm ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1.5 rounded transition"
|
className="text-xs text-gray-400 dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400 px-2 py-1.5 rounded transition"
|
||||||
title="Delete task"
|
title="Delete task"
|
||||||
>
|
>
|
||||||
🗑 Delete
|
🗑 Delete
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 animate-slide-up">
|
<div className="flex items-center gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-3 py-2 animate-slide-up">
|
||||||
<span className="text-xs text-red-700">Delete this task?</span>
|
<span className="text-xs text-red-700 dark:text-red-400">Delete this task?</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
@@ -912,7 +895,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700 transition"
|
className="text-xs px-2 py-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -922,7 +905,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Task ID - click to copy */}
|
|
||||||
<CopyableId id={task.id} taskNumber={task.taskNumber} />
|
<CopyableId id={task.id} taskNumber={task.taskNumber} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
66
frontend/src/hooks/useTheme.tsx
Normal file
66
frontend/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
resolved: "light" | "dark";
|
||||||
|
setTheme: (t: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({
|
||||||
|
theme: "system",
|
||||||
|
resolved: "light",
|
||||||
|
setTheme: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSystemTheme(): "light" | "dark" {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme(theme: Theme): "light" | "dark" {
|
||||||
|
return theme === "system" ? getSystemTheme() : theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => {
|
||||||
|
const stored = localStorage.getItem("hammer-theme");
|
||||||
|
return (stored === "light" || stored === "dark" || stored === "system") ? stored : "system";
|
||||||
|
});
|
||||||
|
const [resolved, setResolved] = useState<"light" | "dark">(() => resolveTheme(theme));
|
||||||
|
|
||||||
|
const setTheme = (t: Theme) => {
|
||||||
|
setThemeState(t);
|
||||||
|
localStorage.setItem("hammer-theme", t);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply class to <html>
|
||||||
|
useEffect(() => {
|
||||||
|
const r = resolveTheme(theme);
|
||||||
|
setResolved(r);
|
||||||
|
document.documentElement.classList.toggle("dark", r === "dark");
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== "system") return;
|
||||||
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handler = () => {
|
||||||
|
const r = resolveTheme("system");
|
||||||
|
setResolved(r);
|
||||||
|
document.documentElement.classList.toggle("dark", r === "dark");
|
||||||
|
};
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@keyframes slide-in-right {
|
@keyframes slide-in-right {
|
||||||
from {
|
from {
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export function ActivityPage() {
|
|||||||
return items;
|
return items;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
// Group by day
|
|
||||||
const groupedActivity = useMemo(() => {
|
const groupedActivity = useMemo(() => {
|
||||||
const filtered =
|
const filtered =
|
||||||
filter === "all"
|
filter === "all"
|
||||||
@@ -77,7 +76,7 @@ export function ActivityPage() {
|
|||||||
|
|
||||||
if (loading && tasks.length === 0) {
|
if (loading && tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-gray-400">
|
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||||
Loading activity...
|
Loading activity...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -85,18 +84,18 @@ export function ActivityPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-14 md:top-0 z-30">
|
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900">📝 Activity Log</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📝 Activity Log</h1>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
{allActivity.length} updates across {tasks.length} tasks
|
{allActivity.length} updates across {tasks.length} tasks
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="all">All Tasks</option>
|
<option value="all">All Tasks</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
@@ -109,25 +108,24 @@ export function ActivityPage() {
|
|||||||
|
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
|
||||||
{groupedActivity.length === 0 ? (
|
{groupedActivity.length === 0 ? (
|
||||||
<div className="text-center text-gray-400 py-12">No activity found</div>
|
<div className="text-center text-gray-400 dark:text-gray-500 py-12">No activity found</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{groupedActivity.map((group) => (
|
{groupedActivity.map((group) => (
|
||||||
<div key={group.date}>
|
<div key={group.date}>
|
||||||
<div className="sticky top-28 md:top-14 z-10 bg-gray-50/95 backdrop-blur-sm py-2 mb-3">
|
<div className="sticky top-28 md:top-14 z-10 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm py-2 mb-3">
|
||||||
<h2 className="text-sm font-semibold text-gray-500">{group.date}</h2>
|
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400">{group.date}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{group.items.map((item, i) => (
|
{group.items.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.task.id}-${i}`}
|
key={`${item.task.id}-${i}`}
|
||||||
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-white transition group"
|
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-white dark:hover:bg-gray-900 transition group"
|
||||||
>
|
>
|
||||||
{/* Timeline dot */}
|
|
||||||
<div className="flex flex-col items-center pt-1.5">
|
<div className="flex flex-col items-center pt-1.5">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0" />
|
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0" />
|
||||||
{i < group.items.length - 1 && (
|
{i < group.items.length - 1 && (
|
||||||
<div className="w-px flex-1 bg-gray-200 mt-1" />
|
<div className="w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,21 +133,21 @@ export function ActivityPage() {
|
|||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
to={`/task/HQ-${item.task.taskNumber}`}
|
to={`/task/HQ-${item.task.taskNumber}`}
|
||||||
className="text-xs font-bold text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded font-mono hover:bg-amber-100 transition"
|
className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded font-mono hover:bg-amber-100 dark:hover:bg-amber-900/50 transition"
|
||||||
>
|
>
|
||||||
HQ-{item.task.taskNumber}
|
HQ-{item.task.taskNumber}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-[10px] text-gray-400">
|
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||||
{formatDate(item.note.timestamp)}
|
{formatDate(item.note.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-gray-300">
|
<span className="text-[10px] text-gray-300 dark:text-gray-600">
|
||||||
({timeAgo(item.note.timestamp)})
|
({timeAgo(item.note.timestamp)})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-700 leading-relaxed">
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||||
{item.note.note}
|
{item.note.note}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400 mt-1 truncate">
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 truncate">
|
||||||
{item.task.title}
|
{item.task.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ function ThreadList({
|
|||||||
const localSessionKeys = new Set(threads.map(t => t.sessionKey));
|
const localSessionKeys = new Set(threads.map(t => t.sessionKey));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full sm:w-64 bg-white border-r border-gray-200 flex flex-col h-full">
|
<div className="w-full sm:w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col h-full">
|
||||||
<div className="p-3 border-b border-gray-100 flex items-center justify-between">
|
<div className="p-3 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-gray-600">Threads</h3>
|
<h3 className="text-sm font-semibold text-gray-600 dark:text-gray-400">Threads</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
@@ -96,24 +96,24 @@ function ThreadList({
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{/* Local threads */}
|
{/* Local threads */}
|
||||||
{threads.length === 0 && !showGatewaySessions ? (
|
{threads.length === 0 && !showGatewaySessions ? (
|
||||||
<div className="p-4 text-sm text-gray-400 text-center">
|
<div className="p-4 text-sm text-gray-400 dark:text-gray-500 text-center">
|
||||||
No threads yet
|
No threads yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
threads.map((thread) => (
|
threads.map((thread) => (
|
||||||
<div
|
<div
|
||||||
key={thread.sessionKey}
|
key={thread.sessionKey}
|
||||||
className={`group relative w-full text-left px-3 py-3 border-b border-gray-50 transition cursor-pointer ${
|
className={`group relative w-full text-left px-3 py-3 border-b border-gray-50 dark:border-gray-800 transition cursor-pointer ${
|
||||||
activeThread === thread.sessionKey
|
activeThread === thread.sessionKey
|
||||||
? "bg-amber-50 border-l-2 border-l-amber-500"
|
? "bg-amber-50 dark:bg-amber-900/20 border-l-2 border-l-amber-500"
|
||||||
: "hover:bg-gray-50"
|
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onSelect(thread.sessionKey)}
|
onClick={() => onSelect(thread.sessionKey)}
|
||||||
>
|
>
|
||||||
{editingKey === thread.sessionKey ? (
|
{editingKey === thread.sessionKey ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="text-sm font-medium text-gray-800 w-full bg-white border border-amber-300 rounded px-1 py-0.5 outline-none"
|
className="text-sm font-medium text-gray-800 dark:text-gray-200 w-full bg-white dark:bg-gray-800 border border-amber-300 dark:border-amber-700 rounded px-1 py-0.5 outline-none"
|
||||||
value={editName}
|
value={editName}
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
onBlur={commitRename}
|
onBlur={commitRename}
|
||||||
@@ -125,7 +125,7 @@ function ThreadList({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="text-sm font-medium text-gray-800 truncate pr-6"
|
className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate pr-6"
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
startRename(thread.sessionKey, thread.name);
|
startRename(thread.sessionKey, thread.name);
|
||||||
@@ -158,13 +158,13 @@ function ThreadList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gateway sessions browser */}
|
{/* Gateway sessions browser */}
|
||||||
<div className="border-t border-gray-200">
|
<div className="border-t border-gray-200 dark:border-gray-800">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onToggleGatewaySessions?.();
|
onToggleGatewaySessions?.();
|
||||||
if (!showGatewaySessions) onBrowseSessions?.();
|
if (!showGatewaySessions) onBrowseSessions?.();
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2.5 text-xs font-semibold text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition flex items-center justify-between"
|
className="w-full px-3 py-2.5 text-xs font-semibold text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span>🔌 Gateway Sessions</span>
|
<span>🔌 Gateway Sessions</span>
|
||||||
<svg
|
<svg
|
||||||
@@ -177,23 +177,23 @@ function ThreadList({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{showGatewaySessions && (
|
{showGatewaySessions && (
|
||||||
<div className="bg-gray-50">
|
<div className="bg-gray-50 dark:bg-gray-800/50">
|
||||||
{loadingGatewaySessions ? (
|
{loadingGatewaySessions ? (
|
||||||
<div className="px-3 py-3 text-xs text-gray-400 text-center">Loading sessions...</div>
|
<div className="px-3 py-3 text-xs text-gray-400 dark:text-gray-500 text-center">Loading sessions...</div>
|
||||||
) : !gatewaySessions || gatewaySessions.length === 0 ? (
|
) : !gatewaySessions || gatewaySessions.length === 0 ? (
|
||||||
<div className="px-3 py-3 text-xs text-gray-400 text-center">No sessions found</div>
|
<div className="px-3 py-3 text-xs text-gray-400 dark:text-gray-500 text-center">No sessions found</div>
|
||||||
) : (
|
) : (
|
||||||
gatewaySessions.filter(s => !localSessionKeys.has(s.sessionKey)).map((session) => (
|
gatewaySessions.filter(s => !localSessionKeys.has(s.sessionKey)).map((session) => (
|
||||||
<div
|
<div
|
||||||
key={session.sessionKey}
|
key={session.sessionKey}
|
||||||
className="px-3 py-2.5 border-b border-gray-100 hover:bg-gray-100 cursor-pointer transition"
|
className="px-3 py-2.5 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition"
|
||||||
onClick={() => onSelect(session.sessionKey)}
|
onClick={() => onSelect(session.sessionKey)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{session.channel === "telegram" ? "📱" : session.kind === "cron" ? "⏰" : "💬"}
|
{session.channel === "telegram" ? "📱" : session.kind === "cron" ? "⏰" : "💬"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-gray-700 truncate">
|
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||||
{session.sessionKey}
|
{session.sessionKey}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +233,7 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
|
|||||||
if (isSystem) {
|
if (isSystem) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center my-2">
|
<div className="text-center my-2">
|
||||||
<span className="text-xs text-gray-400 bg-gray-100 px-3 py-1 rounded-full">
|
<span className="text-xs text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full">
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +255,7 @@ function MessageBubble({ msg }: { msg: ChatMessage }) {
|
|||||||
className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${
|
className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${
|
||||||
isUser
|
isUser
|
||||||
? "bg-blue-500 text-white rounded-br-md"
|
? "bg-blue-500 text-white rounded-br-md"
|
||||||
: "bg-gray-100 text-gray-800 rounded-bl-md"
|
: "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-bl-md"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
@@ -284,7 +284,7 @@ function ThinkingIndicator() {
|
|||||||
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
|
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
|
||||||
🔨
|
🔨
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
|
<div className="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3">
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||||
@@ -352,9 +352,9 @@ function ChatArea({
|
|||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center text-gray-400 py-12">Loading messages...</div>
|
<div className="text-center text-gray-400 dark:text-gray-500 py-12">Loading messages...</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="text-center text-gray-400 py-12">
|
<div className="text-center text-gray-400 dark:text-gray-500 py-12">
|
||||||
<span className="text-4xl block mb-3">🔨</span>
|
<span className="text-4xl block mb-3">🔨</span>
|
||||||
<p className="text-sm">Send a message to start chatting with Hammer</p>
|
<p className="text-sm">Send a message to start chatting with Hammer</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -373,7 +373,7 @@ function ChatArea({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="border-t border-gray-200 bg-white px-4 py-3">
|
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3">
|
||||||
{connectionState === "disconnected" && (
|
{connectionState === "disconnected" && (
|
||||||
<div className="text-xs text-red-500 mb-2 flex items-center gap-1">
|
<div className="text-xs text-red-500 mb-2 flex items-center gap-1">
|
||||||
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
||||||
@@ -395,7 +395,7 @@ function ChatArea({
|
|||||||
placeholder={connected ? "Type a message..." : "Connecting..."}
|
placeholder={connected ? "Type a message..." : "Connecting..."}
|
||||||
disabled={!connected}
|
disabled={!connected}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 resize-none rounded-xl border border-gray-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300 disabled:opacity-50 max-h-32"
|
className="flex-1 resize-none rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 disabled:opacity-50 max-h-32 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
style={{ minHeight: "42px" }}
|
style={{ minHeight: "42px" }}
|
||||||
/>
|
/>
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
@@ -653,19 +653,19 @@ export function ChatPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-3.5rem)] md:h-screen flex flex-col">
|
<div className="h-[calc(100vh-3.5rem)] md:h-screen flex flex-col">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
|
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30">
|
||||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowThreads(!showThreads)}
|
onClick={() => setShowThreads(!showThreads)}
|
||||||
className="sm:hidden p-1 rounded hover:bg-gray-100 transition"
|
className="sm:hidden p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||||
aria-label="Toggle threads"
|
aria-label="Toggle threads"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-lg font-bold text-gray-900">Chat</h1>
|
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">Chat</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -732,7 +732,7 @@ export function ChatPage() {
|
|||||||
connectionState={connectionState}
|
connectionState={connectionState}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-gray-400 p-4 text-center">
|
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 p-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-3xl block mb-2">💬</span>
|
<span className="text-3xl block mb-2">💬</span>
|
||||||
<p>Select or create a thread</p>
|
<p>Select or create a thread</p>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ function timeAgo(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RecentActivity({ tasks }: { tasks: Task[] }) {
|
function RecentActivity({ tasks }: { tasks: Task[] }) {
|
||||||
// Gather all progress notes with task context, sorted by timestamp desc
|
|
||||||
const recentNotes = useMemo(() => {
|
const recentNotes = useMemo(() => {
|
||||||
const notes: { task: Task; note: ProgressNote }[] = [];
|
const notes: { task: Task; note: ProgressNote }[] = [];
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
@@ -44,7 +43,7 @@ function RecentActivity({ tasks }: { tasks: Task[] }) {
|
|||||||
|
|
||||||
if (recentNotes.length === 0) {
|
if (recentNotes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-gray-400 italic py-6 text-center">
|
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-6 text-center">
|
||||||
No recent activity
|
No recent activity
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -54,18 +53,18 @@ function RecentActivity({ tasks }: { tasks: Task[] }) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentNotes.map((item, i) => (
|
{recentNotes.map((item, i) => (
|
||||||
<div key={i} className="flex gap-3">
|
<div key={i} className="flex gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center text-sm shrink-0 mt-0.5">
|
<div className="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-sm shrink-0 mt-0.5">
|
||||||
🔨
|
🔨
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className="text-xs font-bold text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded font-mono">
|
<span className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded font-mono">
|
||||||
HQ-{item.task.taskNumber}
|
HQ-{item.task.taskNumber}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-400">{timeAgo(item.note.timestamp)}</span>
|
<span className="text-xs text-gray-400 dark:text-gray-500">{timeAgo(item.note.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-700 line-clamp-2">{item.note.note}</p>
|
<p className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">{item.note.note}</p>
|
||||||
<p className="text-xs text-gray-400 mt-0.5 truncate">{item.task.title}</p>
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 truncate">{item.task.title}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -108,7 +107,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
if (loading && tasks.length === 0) {
|
if (loading && tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-gray-400">
|
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||||
Loading dashboard...
|
Loading dashboard...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -116,53 +115,53 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-14 md:top-0 z-30">
|
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
|
||||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-4">
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-4">
|
||||||
<h1 className="text-xl font-bold text-gray-900">🔨 Dashboard</h1>
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">🔨 Dashboard</h1>
|
||||||
<p className="text-sm text-gray-400">Overview of Hammer's work</p>
|
<p className="text-sm text-gray-400 dark:text-gray-500">Overview of Hammer's work</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 space-y-6">
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 space-y-6">
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
<StatCard label="Active" value={stats.active} icon="⚡" color="bg-amber-50 border-amber-200 text-amber-800" />
|
<StatCard label="Active" value={stats.active} icon="⚡" color="bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-300" />
|
||||||
<StatCard label="Queued" value={stats.queued} icon="📋" color="bg-blue-50 border-blue-200 text-blue-800" />
|
<StatCard label="Queued" value={stats.queued} icon="📋" color="bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-300" />
|
||||||
<StatCard label="Blocked" value={stats.blocked} icon="🚫" color="bg-red-50 border-red-200 text-red-800" />
|
<StatCard label="Blocked" value={stats.blocked} icon="🚫" color="bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300" />
|
||||||
<StatCard label="Completed" value={stats.completed} icon="✅" color="bg-green-50 border-green-200 text-green-800" />
|
<StatCard label="Completed" value={stats.completed} icon="✅" color="bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Currently Working On */}
|
{/* Currently Working On */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
|
||||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-gray-900">⚡ Currently Working On</h2>
|
<h2 className="font-semibold text-gray-900 dark:text-gray-100">⚡ Currently Working On</h2>
|
||||||
<Link to="/queue" className="text-xs text-amber-600 hover:text-amber-700 font-medium">
|
<Link to="/queue" className="text-xs text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium">
|
||||||
View Queue →
|
View Queue →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{activeTasks.length === 0 ? (
|
{activeTasks.length === 0 ? (
|
||||||
<div className="text-sm text-gray-400 italic py-4 text-center">
|
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-4 text-center">
|
||||||
Hammer is idle — no active tasks
|
Hammer is idle — no active tasks
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{activeTasks.map((task) => (
|
{activeTasks.map((task) => (
|
||||||
<Link to={`/task/HQ-${task.taskNumber}`} key={task.id} className="block">
|
<Link to={`/task/HQ-${task.taskNumber}`} key={task.id} className="block">
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 hover:bg-amber-100 transition">
|
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="relative flex h-2 w-2">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-bold text-amber-700 font-mono">HQ-{task.taskNumber}</span>
|
<span className="text-xs font-bold text-amber-700 dark:text-amber-400 font-mono">HQ-{task.taskNumber}</span>
|
||||||
<span className="text-xs text-amber-600 capitalize px-1.5 py-0.5 bg-amber-200/50 rounded-full">{task.priority}</span>
|
<span className="text-xs text-amber-600 dark:text-amber-400 capitalize px-1.5 py-0.5 bg-amber-200/50 dark:bg-amber-800/50 rounded-full">{task.priority}</span>
|
||||||
{task.projectId && projectMap[task.projectId] && (
|
{task.projectId && projectMap[task.projectId] && (
|
||||||
<span className="text-xs text-sky-600 px-1.5 py-0.5 bg-sky-100 rounded-full">📁 {projectMap[task.projectId]}</span>
|
<span className="text-xs text-sky-600 dark:text-sky-400 px-1.5 py-0.5 bg-sky-100 dark:bg-sky-900/30 rounded-full">📁 {projectMap[task.projectId]}</span>
|
||||||
)}
|
)}
|
||||||
{task.assigneeName && (
|
{task.assigneeName && (
|
||||||
<span className="text-xs text-emerald-600 px-1.5 py-0.5 bg-emerald-100 rounded-full">👤 {task.assigneeName}</span>
|
<span className="text-xs text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">👤 {task.assigneeName}</span>
|
||||||
)}
|
)}
|
||||||
{task.dueDate && (() => {
|
{task.dueDate && (() => {
|
||||||
const due = new Date(task.dueDate);
|
const due = new Date(task.dueDate);
|
||||||
@@ -170,23 +169,23 @@ export function DashboardPage() {
|
|||||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||||
const isOverdue = diffMs < 0;
|
const isOverdue = diffMs < 0;
|
||||||
return isOverdue || diffDays <= 2 ? (
|
return isOverdue || diffDays <= 2 ? (
|
||||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${isOverdue ? "bg-red-200 text-red-700" : "bg-amber-200 text-amber-700"}`}>
|
<span className={`text-xs px-1.5 py-0.5 rounded-full ${isOverdue ? "bg-red-200 dark:bg-red-900/30 text-red-700 dark:text-red-400" : "bg-amber-200 dark:bg-amber-800/50 text-amber-700 dark:text-amber-400"}`}>
|
||||||
📅 {isOverdue ? "overdue" : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`}
|
📅 {isOverdue ? "overdue" : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`}
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-medium text-sm text-amber-900">{task.title}</h3>
|
<h3 className="font-medium text-sm text-amber-900 dark:text-amber-200">{task.title}</h3>
|
||||||
{task.subtasks?.length > 0 && (
|
{task.subtasks?.length > 0 && (
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
<div className="flex-1 bg-amber-200/50 rounded-full h-1">
|
<div className="flex-1 bg-amber-200/50 dark:bg-amber-800/30 rounded-full h-1">
|
||||||
<div className="bg-green-500 h-1 rounded-full" style={{ width: `${(task.subtasks.filter(s => s.completed).length / task.subtasks.length) * 100}%` }} />
|
<div className="bg-green-500 h-1 rounded-full" style={{ width: `${(task.subtasks.filter(s => s.completed).length / task.subtasks.length) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-amber-600">{task.subtasks.filter(s => s.completed).length}/{task.subtasks.length}</span>
|
<span className="text-xs text-amber-600 dark:text-amber-400">{task.subtasks.filter(s => s.completed).length}/{task.subtasks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{task.progressNotes?.length > 0 && (
|
{task.progressNotes?.length > 0 && (
|
||||||
<p className="text-xs text-amber-700 mt-1 line-clamp-2 opacity-70">
|
<p className="text-xs text-amber-700 dark:text-amber-400 mt-1 line-clamp-2 opacity-70">
|
||||||
Latest: {task.progressNotes[task.progressNotes.length - 1].note}
|
Latest: {task.progressNotes[task.progressNotes.length - 1].note}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -198,16 +197,16 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
{/* Up Next */}
|
{/* Up Next */}
|
||||||
{upNext.length > 0 && (
|
{upNext.length > 0 && (
|
||||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Up Next</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Up Next</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{upNext.map((task, i) => (
|
{upNext.map((task, i) => (
|
||||||
<Link to={`/task/HQ-${task.taskNumber}`} key={task.id} className="flex items-center gap-2 text-sm group hover:bg-gray-50 rounded-lg px-1 -mx-1 py-0.5 transition">
|
<Link to={`/task/HQ-${task.taskNumber}`} key={task.id} className="flex items-center gap-2 text-sm group hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg px-1 -mx-1 py-0.5 transition">
|
||||||
<span className="text-gray-300 text-xs w-4 text-right">{i + 1}.</span>
|
<span className="text-gray-300 dark:text-gray-600 text-xs w-4 text-right">{i + 1}.</span>
|
||||||
<span className="text-xs font-mono text-gray-400">HQ-{task.taskNumber}</span>
|
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">HQ-{task.taskNumber}</span>
|
||||||
<span className="text-gray-700 truncate group-hover:text-amber-700 transition">{task.title}</span>
|
<span className="text-gray-700 dark:text-gray-300 truncate group-hover:text-amber-700 dark:group-hover:text-amber-400 transition">{task.title}</span>
|
||||||
{task.priority === "high" || task.priority === "critical" ? (
|
{task.priority === "high" || task.priority === "critical" ? (
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 ${task.priority === "critical" ? "bg-red-100 text-red-600" : "bg-orange-100 text-orange-600"}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0 ${task.priority === "critical" ? "bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400" : "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400"}`}>
|
||||||
{task.priority}
|
{task.priority}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -220,9 +219,9 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
|
||||||
<div className="px-5 py-4 border-b border-gray-100">
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h2 className="font-semibold text-gray-900">📝 Recent Activity</h2>
|
<h2 className="font-semibold text-gray-900 dark:text-gray-100">📝 Recent Activity</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<RecentActivity tasks={tasks} />
|
<RecentActivity tasks={tasks} />
|
||||||
@@ -232,21 +231,21 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
{/* Recently Completed */}
|
{/* Recently Completed */}
|
||||||
{recentlyCompleted.length > 0 && (
|
{recentlyCompleted.length > 0 && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm">
|
||||||
<div className="px-5 py-4 border-b border-gray-100">
|
<div className="px-5 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||||
<h2 className="font-semibold text-gray-900">✅ Recently Completed</h2>
|
<h2 className="font-semibold text-gray-900 dark:text-gray-100">✅ Recently Completed</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
{recentlyCompleted.map((task) => (
|
{recentlyCompleted.map((task) => (
|
||||||
<Link to={`/task/HQ-${task.taskNumber}`} key={task.id} className="rounded-lg border border-green-200 bg-green-50 p-3 hover:bg-green-100 transition block">
|
<Link to={`/task/HQ-${task.taskNumber}`} key={task.id} className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-3 hover:bg-green-100 dark:hover:bg-green-900/30 transition block">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-xs font-bold text-green-700 font-mono">HQ-{task.taskNumber}</span>
|
<span className="text-xs font-bold text-green-700 dark:text-green-400 font-mono">HQ-{task.taskNumber}</span>
|
||||||
{task.completedAt && (
|
{task.completedAt && (
|
||||||
<span className="text-xs text-green-600">{timeAgo(task.completedAt)}</span>
|
<span className="text-xs text-green-600 dark:text-green-500">{timeAgo(task.completedAt)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-medium text-sm text-green-900">{task.title}</h3>
|
<h3 className="font-medium text-sm text-green-900 dark:text-green-200">{task.title}</h3>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -141,12 +141,12 @@ export function QueuePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-14 md:top-0 z-30">
|
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg sm:text-xl font-bold text-gray-900">Task Queue</h1>
|
<h1 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100">Task Queue</h1>
|
||||||
<p className="text-xs sm:text-sm text-gray-400">
|
<p className="text-xs sm:text-sm text-gray-400 dark:text-gray-500">
|
||||||
Manage what Hammer is working on
|
Manage what Hammer is working on
|
||||||
{filteredTasks.length !== tasks.length && (
|
{filteredTasks.length !== tasks.length && (
|
||||||
<span className="ml-1 text-amber-500">· {filteredTasks.length} of {tasks.length} shown</span>
|
<span className="ml-1 text-amber-500">· {filteredTasks.length} of {tasks.length} shown</span>
|
||||||
@@ -155,10 +155,10 @@ export function QueuePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
<div className="hidden sm:flex items-center bg-gray-100 rounded-lg p-0.5">
|
<div className="hidden sm:flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode("list")}
|
onClick={() => setViewMode("list")}
|
||||||
className={`p-1.5 rounded-md transition ${viewMode === "list" ? "bg-white shadow-sm text-amber-600" : "text-gray-400 hover:text-gray-600"}`}
|
className={`p-1.5 rounded-md transition ${viewMode === "list" ? "bg-white dark:bg-gray-700 shadow-sm text-amber-600 dark:text-amber-400" : "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"}`}
|
||||||
title="List view"
|
title="List view"
|
||||||
aria-label="List view"
|
aria-label="List view"
|
||||||
>
|
>
|
||||||
@@ -168,7 +168,7 @@ export function QueuePage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode("board")}
|
onClick={() => setViewMode("board")}
|
||||||
className={`p-1.5 rounded-md transition ${viewMode === "board" ? "bg-white shadow-sm text-amber-600" : "text-gray-400 hover:text-gray-600"}`}
|
className={`p-1.5 rounded-md transition ${viewMode === "board" ? "bg-white dark:bg-gray-700 shadow-sm text-amber-600 dark:text-amber-400" : "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"}`}
|
||||||
title="Board view"
|
title="Board view"
|
||||||
aria-label="Board view"
|
aria-label="Board view"
|
||||||
>
|
>
|
||||||
@@ -188,7 +188,7 @@ export function QueuePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
@@ -196,7 +196,7 @@ export function QueuePage() {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Search tasks..."
|
placeholder="Search tasks..."
|
||||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300"
|
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
/>
|
/>
|
||||||
{search && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
@@ -212,7 +212,7 @@ export function QueuePage() {
|
|||||||
<select
|
<select
|
||||||
value={filterPriority}
|
value={filterPriority}
|
||||||
onChange={(e) => setFilterPriority(e.target.value)}
|
onChange={(e) => setFilterPriority(e.target.value)}
|
||||||
className="text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white"
|
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="">All priorities</option>
|
<option value="">All priorities</option>
|
||||||
<option value="critical">🔴 Critical</option>
|
<option value="critical">🔴 Critical</option>
|
||||||
@@ -224,7 +224,7 @@ export function QueuePage() {
|
|||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
className="text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white"
|
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
<option value="active">⚡ Active</option>
|
<option value="active">⚡ Active</option>
|
||||||
@@ -265,10 +265,10 @@ export function QueuePage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 sm:py-6 space-y-5 sm:space-y-6">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4 sm:py-6 space-y-5 sm:space-y-6">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-center text-gray-400 py-12">Loading tasks...</div>
|
<div className="text-center text-gray-400 dark:text-gray-500 py-12">Loading tasks...</div>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-sm">
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 rounded-lg p-3 text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -276,11 +276,11 @@ export function QueuePage() {
|
|||||||
{/* Active Task */}
|
{/* Active Task */}
|
||||||
{showSection("active") && (
|
{showSection("active") && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
||||||
⚡ Currently Working On
|
⚡ Currently Working On
|
||||||
</h2>
|
</h2>
|
||||||
{activeTasks.length === 0 ? (
|
{activeTasks.length === 0 ? (
|
||||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center text-gray-400">
|
<div className="border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center text-gray-400 dark:text-gray-500">
|
||||||
No active task — Hammer is idle
|
No active task — Hammer is idle
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -303,7 +303,7 @@ export function QueuePage() {
|
|||||||
{/* Blocked */}
|
{/* Blocked */}
|
||||||
{showSection("blocked") && blockedTasks.length > 0 && (
|
{showSection("blocked") && blockedTasks.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
||||||
🚫 Blocked ({blockedTasks.length})
|
🚫 Blocked ({blockedTasks.length})
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -323,11 +323,11 @@ export function QueuePage() {
|
|||||||
{/* Queue */}
|
{/* Queue */}
|
||||||
{showSection("queued") && (
|
{showSection("queued") && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
||||||
📋 Queue ({queuedTasks.length})
|
📋 Queue ({queuedTasks.length})
|
||||||
</h2>
|
</h2>
|
||||||
{queuedTasks.length === 0 ? (
|
{queuedTasks.length === 0 ? (
|
||||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center text-gray-400">
|
<div className="border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center text-gray-400 dark:text-gray-500">
|
||||||
Queue is empty
|
Queue is empty
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -355,7 +355,7 @@ export function QueuePage() {
|
|||||||
<section>
|
<section>
|
||||||
{filterStatus ? (
|
{filterStatus ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
||||||
{filterStatus === "completed" ? "✅" : "❌"} {filterStatus.charAt(0).toUpperCase() + filterStatus.slice(1)} ({completedTasks.length})
|
{filterStatus === "completed" ? "✅" : "❌"} {filterStatus.charAt(0).toUpperCase() + filterStatus.slice(1)} ({completedTasks.length})
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-2 opacity-60">
|
<div className="space-y-2 opacity-60">
|
||||||
@@ -374,7 +374,7 @@ export function QueuePage() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCompleted(!showCompleted)}
|
onClick={() => setShowCompleted(!showCompleted)}
|
||||||
className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-1 hover:text-gray-700"
|
className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3 flex items-center gap-1 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
>
|
>
|
||||||
{showCompleted ? "▾" : "▸"} Completed / Cancelled ({completedTasks.length})
|
{showCompleted ? "▾" : "▸"} Completed / Cancelled ({completedTasks.length})
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useParams, Link, useNavigate } from "react-router-dom";
|
import { useParams, Link, useNavigate } from "react-router-dom";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Task, TaskStatus, Project } from "../lib/types";
|
import type { Task, TaskStatus, Project } from "../lib/types";
|
||||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||||
import { useToast } from "../components/Toast";
|
import { useToast } from "../components/Toast";
|
||||||
@@ -12,11 +14,11 @@ const priorityColors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
active: "bg-amber-100 text-amber-800 border-amber-300",
|
active: "bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 border-amber-300 dark:border-amber-700",
|
||||||
queued: "bg-blue-100 text-blue-800 border-blue-300",
|
queued: "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-300 dark:border-blue-700",
|
||||||
blocked: "bg-red-100 text-red-800 border-red-300",
|
blocked: "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 border-red-300 dark:border-red-700",
|
||||||
completed: "bg-green-100 text-green-800 border-green-300",
|
completed: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-300 dark:border-green-700",
|
||||||
cancelled: "bg-gray-100 text-gray-600 border-gray-300",
|
cancelled: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusIcons: Record<string, string> = {
|
const statusIcons: Record<string, string> = {
|
||||||
@@ -29,20 +31,20 @@ const statusIcons: Record<string, string> = {
|
|||||||
|
|
||||||
const statusActions: Record<string, { label: string; next: TaskStatus; color: string }[]> = {
|
const statusActions: Record<string, { label: string; next: TaskStatus; color: string }[]> = {
|
||||||
active: [
|
active: [
|
||||||
{ label: "⏸ Pause", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" },
|
{ label: "⏸ Pause", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/40" },
|
||||||
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" },
|
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40" },
|
||||||
{ label: "✅ Complete", next: "completed", color: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100" },
|
{ label: "✅ Complete", next: "completed", color: "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-200 dark:border-green-700 hover:bg-green-100 dark:hover:bg-green-900/40" },
|
||||||
],
|
],
|
||||||
queued: [
|
queued: [
|
||||||
{ label: "▶ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" },
|
{ label: "▶ Activate", next: "active", color: "bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/40" },
|
||||||
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" },
|
{ label: "🚫 Block", next: "blocked", color: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40" },
|
||||||
],
|
],
|
||||||
blocked: [
|
blocked: [
|
||||||
{ label: "▶ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" },
|
{ label: "▶ Activate", next: "active", color: "bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/40" },
|
||||||
{ label: "📋 Queue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" },
|
{ label: "📋 Queue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/40" },
|
||||||
],
|
],
|
||||||
completed: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }],
|
completed: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/40" }],
|
||||||
cancelled: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }],
|
cancelled: [{ label: "🔄 Requeue", next: "queued", color: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700 hover:bg-blue-100 dark:hover:bg-blue-900/40" }],
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
@@ -63,6 +65,9 @@ function timeAgo(dateStr: string): string {
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Markdown prose classes for descriptions and notes
|
||||||
|
const proseClasses = "prose prose-sm prose-gray dark:prose-invert max-w-none [&_pre]:bg-gray-800 dark:[&_pre]:bg-gray-900 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 dark:[&_code]:bg-gray-700 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 dark:[&_a]:text-amber-400 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 dark:[&_blockquote]:border-gray-600 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500 dark:[&_blockquote]:text-gray-400";
|
||||||
|
|
||||||
export function TaskPage() {
|
export function TaskPage() {
|
||||||
const { taskRef } = useParams<{ taskRef: string }>();
|
const { taskRef } = useParams<{ taskRef: string }>();
|
||||||
const [task, setTask] = useState<Task | null>(null);
|
const [task, setTask] = useState<Task | null>(null);
|
||||||
@@ -76,6 +81,14 @@ export function TaskPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
// Description editing
|
||||||
|
const [editingDescription, setEditingDescription] = useState(false);
|
||||||
|
const [descriptionDraft, setDescriptionDraft] = useState("");
|
||||||
|
const [savingDescription, setSavingDescription] = useState(false);
|
||||||
|
// Title editing
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
|
const [titleDraft, setTitleDraft] = useState("");
|
||||||
|
const [savingTitle, setSavingTitle] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -120,6 +133,38 @@ export function TaskPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveDescription = async () => {
|
||||||
|
if (!task) return;
|
||||||
|
setSavingDescription(true);
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { description: descriptionDraft });
|
||||||
|
fetchTask();
|
||||||
|
setEditingDescription(false);
|
||||||
|
toast("Description updated", "success");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save description:", e);
|
||||||
|
toast("Failed to save description", "error");
|
||||||
|
} finally {
|
||||||
|
setSavingDescription(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTitle = async () => {
|
||||||
|
if (!task || !titleDraft.trim()) return;
|
||||||
|
setSavingTitle(true);
|
||||||
|
try {
|
||||||
|
await updateTask(task.id, { title: titleDraft.trim() });
|
||||||
|
fetchTask();
|
||||||
|
setEditingTitle(false);
|
||||||
|
toast("Title updated", "success");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save title:", e);
|
||||||
|
toast("Failed to save title", "error");
|
||||||
|
} finally {
|
||||||
|
setSavingTitle(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
@@ -138,7 +183,7 @@ export function TaskPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-gray-400">
|
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||||
Loading task...
|
Loading task...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -149,8 +194,8 @@ export function TaskPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="text-4xl block mb-3">😕</span>
|
<span className="text-4xl block mb-3">😕</span>
|
||||||
<p className="text-gray-500 mb-4">{error || "Task not found"}</p>
|
<p className="text-gray-500 dark:text-gray-400 mb-4">{error || "Task not found"}</p>
|
||||||
<Link to="/queue" className="text-amber-600 hover:text-amber-700 font-medium text-sm">
|
<Link to="/queue" className="text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium text-sm">
|
||||||
← Back to Queue
|
← Back to Queue
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,14 +213,18 @@ export function TaskPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className={`sticky top-14 md:top-0 z-30 border-b ${isActive ? "bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200" : "bg-white border-gray-200"}`}>
|
<header className={`sticky top-14 md:top-0 z-30 border-b ${
|
||||||
|
isActive
|
||||||
|
? "bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border-amber-200 dark:border-amber-800"
|
||||||
|
: "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-800"
|
||||||
|
}`}>
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Link to="/queue" className="text-sm text-gray-400 hover:text-gray-600 transition">
|
<Link to="/queue" className="text-sm text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition">
|
||||||
← Queue
|
← Queue
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-gray-300">/</span>
|
<span className="text-gray-300 dark:text-gray-600">/</span>
|
||||||
<span className="text-xs font-bold text-amber-700 bg-amber-100 px-2 py-0.5 rounded font-mono">
|
<span className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded font-mono">
|
||||||
HQ-{task.taskNumber}
|
HQ-{task.taskNumber}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,15 +244,51 @@ export function TaskPage() {
|
|||||||
{task.priority}
|
{task.priority}
|
||||||
</span>
|
</span>
|
||||||
{project && (
|
{project && (
|
||||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
|
<span className="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
||||||
📁 {project.name}
|
📁 {project.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-gray-900">{task.title}</h1>
|
{editingTitle ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={(e) => setTitleDraft(e.target.value)}
|
||||||
|
className="text-xl font-bold text-gray-900 dark:text-gray-100 bg-transparent border-b-2 border-amber-400 dark:border-amber-500 outline-none flex-1 py-0.5"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSaveTitle();
|
||||||
|
if (e.key === "Escape") setEditingTitle(false);
|
||||||
|
}}
|
||||||
|
disabled={savingTitle}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveTitle}
|
||||||
|
disabled={savingTitle || !titleDraft.trim()}
|
||||||
|
className="text-xs px-2.5 py-1 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingTitle ? "..." : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingTitle(false)}
|
||||||
|
className="text-xs px-2.5 py-1 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h1
|
||||||
|
className="text-xl font-bold text-gray-900 dark:text-gray-100 cursor-pointer hover:text-amber-700 dark:hover:text-amber-400 transition group"
|
||||||
|
onClick={() => { setTitleDraft(task.title); setEditingTitle(true); }}
|
||||||
|
title="Click to edit title"
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
<span className="opacity-0 group-hover:opacity-100 text-gray-400 dark:text-gray-500 text-sm ml-2 transition">✏️</span>
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Status Actions */}
|
{/* Status Actions */}
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0 flex-wrap justify-end">
|
||||||
{actions.map((action) => (
|
{actions.map((action) => (
|
||||||
<button
|
<button
|
||||||
key={action.next}
|
key={action.next}
|
||||||
@@ -226,9 +311,13 @@ export function TaskPage() {
|
|||||||
const isDueSoon = diffDays <= 2 && !isOverdue;
|
const isDueSoon = diffDays <= 2 && !isOverdue;
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<span className="text-xs text-gray-500">📅 Due:</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">📅 Due:</span>
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||||
isOverdue ? "bg-red-100 text-red-700" : isDueSoon ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-600"
|
isOverdue
|
||||||
|
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
|
||||||
|
: isDueSoon
|
||||||
|
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
|
||||||
|
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||||
}`}>
|
}`}>
|
||||||
{formatDate(task.dueDate)} ({isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d left`})
|
{formatDate(task.dueDate)} ({isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d left`})
|
||||||
</span>
|
</span>
|
||||||
@@ -243,30 +332,80 @@ export function TaskPage() {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Description</h2>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">
|
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Description</h2>
|
||||||
{task.description || <span className="text-gray-400 italic">No description</span>}
|
{!editingDescription && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setDescriptionDraft(task.description || ""); setEditingDescription(true); }}
|
||||||
|
className="text-xs text-gray-400 dark:text-gray-500 hover:text-amber-600 dark:hover:text-amber-400 transition font-medium"
|
||||||
|
>
|
||||||
|
✏️ Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingDescription ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={descriptionDraft}
|
||||||
|
onChange={(e) => setDescriptionDraft(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
className="w-full text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-y min-h-[120px] font-mono placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
|
placeholder="Describe the task... (Markdown supported)"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-gray-400 dark:text-gray-500">Markdown supported</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingDescription(false)}
|
||||||
|
className="text-xs px-3 py-1.5 bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 rounded-lg font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveDescription}
|
||||||
|
disabled={savingDescription}
|
||||||
|
className="text-xs px-3 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingDescription ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : task.description ? (
|
||||||
|
<div className={`text-sm leading-relaxed ${proseClasses}`}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{task.description}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p
|
||||||
|
className="text-sm text-gray-400 dark:text-gray-500 italic cursor-pointer hover:text-amber-500 dark:hover:text-amber-400 transition py-4 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg"
|
||||||
|
onClick={() => { setDescriptionDraft(""); setEditingDescription(true); }}
|
||||||
|
>
|
||||||
|
No description — click to add one
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtasks */}
|
{/* Subtasks */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">
|
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||||
Subtasks {task.subtasks?.length > 0 && (
|
Subtasks {task.subtasks?.length > 0 && (
|
||||||
<span className="text-gray-300 ml-1">({task.subtasks.filter(s => s.completed).length}/{task.subtasks.length})</span>
|
<span className="text-gray-300 dark:text-gray-600 ml-1">({task.subtasks.filter(s => s.completed).length}/{task.subtasks.length})</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
{task.subtasks?.length > 0 && (
|
{task.subtasks?.length > 0 && (
|
||||||
<span className="text-xs text-gray-400">{subtaskProgress}%</span>
|
<span className="text-xs text-gray-400 dark:text-gray-500">{subtaskProgress}%</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
{task.subtasks?.length > 0 && (
|
{task.subtasks?.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${subtaskProgress}%` }}
|
style={{ width: `${subtaskProgress}%` }}
|
||||||
@@ -279,7 +418,7 @@ export function TaskPage() {
|
|||||||
{task.subtasks?.length > 0 && (
|
{task.subtasks?.length > 0 && (
|
||||||
<div className="space-y-1 mb-4">
|
<div className="space-y-1 mb-4">
|
||||||
{task.subtasks.map((subtask) => (
|
{task.subtasks.map((subtask) => (
|
||||||
<div key={subtask.id} className="flex items-center gap-3 group py-1.5 px-2 rounded-lg hover:bg-gray-50 transition">
|
<div key={subtask.id} className="flex items-center gap-3 group py-1.5 px-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition">
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -292,7 +431,7 @@ export function TaskPage() {
|
|||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition ${
|
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition ${
|
||||||
subtask.completed
|
subtask.completed
|
||||||
? "bg-green-500 border-green-500 text-white"
|
? "bg-green-500 border-green-500 text-white"
|
||||||
: "border-gray-300 hover:border-amber-400"
|
: "border-gray-300 dark:border-gray-600 hover:border-amber-400 dark:hover:border-amber-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{subtask.completed && (
|
{subtask.completed && (
|
||||||
@@ -301,7 +440,7 @@ export function TaskPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span className={`text-sm flex-1 ${subtask.completed ? "line-through text-gray-400" : "text-gray-700"}`}>
|
<span className={`text-sm flex-1 ${subtask.completed ? "line-through text-gray-400 dark:text-gray-500" : "text-gray-700 dark:text-gray-300"}`}>
|
||||||
{subtask.title}
|
{subtask.title}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -313,7 +452,7 @@ export function TaskPage() {
|
|||||||
console.error("Failed to delete subtask:", e);
|
console.error("Failed to delete subtask:", e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition p-0.5"
|
className="opacity-0 group-hover:opacity-100 text-gray-300 dark:text-gray-600 hover:text-red-400 dark:hover:text-red-400 transition p-0.5"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -331,7 +470,7 @@ export function TaskPage() {
|
|||||||
value={newSubtaskTitle}
|
value={newSubtaskTitle}
|
||||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||||
placeholder="Add a subtask..."
|
placeholder="Add a subtask..."
|
||||||
className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300"
|
className="flex-1 text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
if (e.key === "Enter" && newSubtaskTitle.trim()) {
|
if (e.key === "Enter" && newSubtaskTitle.trim()) {
|
||||||
setAddingSubtask(true);
|
setAddingSubtask(true);
|
||||||
@@ -371,10 +510,10 @@ export function TaskPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Notes */}
|
{/* Progress Notes */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||||
Progress Notes {task.progressNotes?.length > 0 && (
|
Progress Notes {task.progressNotes?.length > 0 && (
|
||||||
<span className="text-gray-300 ml-1">({task.progressNotes.length})</span>
|
<span className="text-gray-300 dark:text-gray-600 ml-1">({task.progressNotes.length})</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -386,7 +525,7 @@ export function TaskPage() {
|
|||||||
onChange={(e) => setNoteText(e.target.value)}
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
placeholder="Add a progress note..."
|
placeholder="Add a progress note..."
|
||||||
rows={2}
|
rows={2}
|
||||||
className="flex-1 text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 focus:border-amber-300 resize-y min-h-[40px] max-h-32"
|
className="flex-1 text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-y min-h-[40px] max-h-32 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -415,12 +554,12 @@ export function TaskPage() {
|
|||||||
{addingNote ? "..." : "Add"}
|
{addingNote ? "..." : "Add"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-gray-400 mt-1">⌘+Enter to submit</p>
|
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-1">⌘+Enter to submit</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes list */}
|
{/* Notes list */}
|
||||||
{!task.progressNotes || task.progressNotes.length === 0 ? (
|
{!task.progressNotes || task.progressNotes.length === 0 ? (
|
||||||
<div className="text-sm text-gray-400 italic py-6 text-center border-2 border-dashed border-gray-100 rounded-lg">
|
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-6 text-center border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-lg">
|
||||||
No progress notes yet
|
No progress notes yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -431,16 +570,18 @@ export function TaskPage() {
|
|||||||
.map((note, i) => (
|
.map((note, i) => (
|
||||||
<div key={i} className="relative pl-6 pb-4 last:pb-0 group">
|
<div key={i} className="relative pl-6 pb-4 last:pb-0 group">
|
||||||
{i < task.progressNotes.length - 1 && (
|
{i < task.progressNotes.length - 1 && (
|
||||||
<div className="absolute left-[9px] top-3 bottom-0 w-0.5 bg-gray-200 group-last:hidden" />
|
<div className="absolute left-[9px] top-3 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700 group-last:hidden" />
|
||||||
)}
|
)}
|
||||||
<div className={`absolute left-0 top-1 w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center ${
|
<div className={`absolute left-0 top-1 w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center ${
|
||||||
i === 0 && isActive ? "border-amber-400 bg-amber-50" : "border-gray-300 bg-white"
|
i === 0 && isActive
|
||||||
|
? "border-amber-400 dark:border-amber-500 bg-amber-50 dark:bg-amber-900/30"
|
||||||
|
: "border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900"
|
||||||
}`}>
|
}`}>
|
||||||
<div className={`w-2 h-2 rounded-full ${i === 0 && isActive ? "bg-amber-500" : "bg-gray-300"}`} />
|
<div className={`w-2 h-2 rounded-full ${i === 0 && isActive ? "bg-amber-500" : "bg-gray-300 dark:bg-gray-600"}`} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700 leading-relaxed">{note.note}</p>
|
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{note.note}</p>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">{formatDate(note.timestamp)} · {timeAgo(note.timestamp)}</p>
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{formatDate(note.timestamp)} · {timeAgo(note.timestamp)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -452,49 +593,49 @@ export function TaskPage() {
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-4">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Details</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Details</h3>
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Source</span>
|
<span className="text-gray-500 dark:text-gray-400">Source</span>
|
||||||
<span className="text-gray-700 font-medium capitalize">{task.source}</span>
|
<span className="text-gray-700 dark:text-gray-300 font-medium capitalize">{task.source}</span>
|
||||||
</div>
|
</div>
|
||||||
{task.assigneeName && (
|
{task.assigneeName && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Assignee</span>
|
<span className="text-gray-500 dark:text-gray-400">Assignee</span>
|
||||||
<span className="text-gray-700 font-medium">{task.assigneeName}</span>
|
<span className="text-gray-700 dark:text-gray-300 font-medium">{task.assigneeName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{project && (
|
{project && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Project</span>
|
<span className="text-gray-500 dark:text-gray-400">Project</span>
|
||||||
<span className="text-gray-700 font-medium">{project.name}</span>
|
<span className="text-gray-700 dark:text-gray-300 font-medium">{project.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Created</span>
|
<span className="text-gray-500 dark:text-gray-400">Created</span>
|
||||||
<span className="text-gray-700 text-xs">{timeAgo(task.createdAt)}</span>
|
<span className="text-gray-700 dark:text-gray-300 text-xs">{timeAgo(task.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{task.updatedAt !== task.createdAt && (
|
{task.updatedAt !== task.createdAt && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Updated</span>
|
<span className="text-gray-500 dark:text-gray-400">Updated</span>
|
||||||
<span className="text-gray-700 text-xs">{timeAgo(task.updatedAt)}</span>
|
<span className="text-gray-700 dark:text-gray-300 text-xs">{timeAgo(task.updatedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{task.completedAt && (
|
{task.completedAt && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Completed</span>
|
<span className="text-gray-500 dark:text-gray-400">Completed</span>
|
||||||
<span className="text-gray-700 text-xs">{timeAgo(task.completedAt)}</span>
|
<span className="text-gray-700 dark:text-gray-300 text-xs">{timeAgo(task.completedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick link */}
|
{/* Quick link */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-4">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Share</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Share</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="text-xs text-gray-500 font-mono bg-gray-50 px-2 py-1 rounded flex-1 truncate">
|
<code className="text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-50 dark:bg-gray-800 px-2 py-1 rounded flex-1 truncate">
|
||||||
/task/HQ-{task.taskNumber}
|
/task/HQ-{task.taskNumber}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
@@ -503,7 +644,7 @@ export function TaskPage() {
|
|||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
toast("Link copied", "info");
|
toast("Link copied", "info");
|
||||||
}}
|
}}
|
||||||
className="text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200 transition"
|
className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||||
>
|
>
|
||||||
📋
|
📋
|
||||||
</button>
|
</button>
|
||||||
@@ -511,18 +652,18 @@ export function TaskPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Danger zone */}
|
{/* Danger zone */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
|
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-4">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Danger Zone</h3>
|
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Danger Zone</h3>
|
||||||
{!showDeleteConfirm ? (
|
{!showDeleteConfirm ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
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"
|
className="w-full text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 border border-red-200 dark:border-red-800 rounded-lg px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 transition font-medium"
|
||||||
>
|
>
|
||||||
🗑 Delete Task
|
🗑 Delete Task
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 animate-slide-up">
|
<div className="space-y-2 animate-slide-up">
|
||||||
<p className="text-xs text-red-700">This cannot be undone. Are you sure?</p>
|
<p className="text-xs text-red-700 dark:text-red-400">This cannot be undone. Are you sure?</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
@@ -533,7 +674,7 @@ export function TaskPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
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"
|
className="flex-1 text-sm px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user