diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 61956da..4e7478d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { DashboardLayout } from "./components/DashboardLayout"; import { LoginPage } from "./components/LoginPage"; import { ToastProvider } from "./components/Toast"; +import { ThemeProvider } from "./hooks/useTheme"; import { useSession } from "./lib/auth-client"; // Lazy-loaded pages for code splitting @@ -50,20 +51,26 @@ function App() { if (session.isPending) { return ( -
+
Loading...
); } if (!session.data) { - return window.location.reload()} />; + return ( + + window.location.reload()} /> + + ); } return ( - - - + + + + + ); } diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index 4b5457e..3c2b9c6 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { useCurrentUser } from "../hooks/useCurrentUser"; +import { useTheme } from "../hooks/useTheme"; import { signOut } from "../lib/auth-client"; const navItems = [ @@ -13,8 +14,16 @@ const navItems = [ export function DashboardLayout() { const { user, isAdmin } = useCurrentUser(); + const { theme, setTheme } = useTheme(); 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 () => { await signOut(); window.location.reload(); @@ -23,7 +32,7 @@ export function DashboardLayout() { const closeSidebar = () => setSidebarOpen(false); return ( -
+
{/* Mobile header */}
+ -

+

Invite-only access ยท Contact admin for an account

diff --git a/frontend/src/components/TaskCard.tsx b/frontend/src/components/TaskCard.tsx index 51dd510..ff0d5b4 100644 --- a/frontend/src/components/TaskCard.tsx +++ b/frontend/src/components/TaskCard.tsx @@ -8,12 +8,12 @@ const priorityColors: Record = { }; const sourceColors: Record = { - donovan: "bg-purple-100 text-purple-800", - david: "bg-green-100 text-green-800", - hammer: "bg-yellow-100 text-yellow-800", - heartbeat: "bg-pink-100 text-pink-800", - cron: "bg-indigo-100 text-indigo-800", - other: "bg-gray-100 text-gray-800", + donovan: "bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300", + david: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300", + hammer: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300", + heartbeat: "bg-pink-100 dark:bg-pink-900/30 text-pink-800 dark:text-pink-300", + cron: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-300", + other: "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300", }; const statusActions: Record = { @@ -76,8 +76,8 @@ export function TaskCard({ onClick={onClick} className={`rounded-xl border p-3 sm:p-4 transition-all cursor-pointer group ${ 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-gray-200 bg-white shadow-sm hover:shadow-md hover:border-gray-300" + ? "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 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 */} @@ -90,14 +90,14 @@ export function TaskCard({ )} -

+

{task.title}

{ e.stopPropagation(); @@ -112,30 +112,28 @@ export function TaskCard({ {task.source} - {/* Project badge */} {projectName && ( - + ๐Ÿ“ {projectName} )} - {/* Assignee badge */} {task.assigneeName && ( - + ๐Ÿ‘ค {task.assigneeName} )} - + {timeAgo(task.createdAt)} {noteCount > 0 && ( - + ๐Ÿ’ฌ {noteCount} )}
{task.description && ( -

{task.description}

+

{task.description}

)} {/* Due date and subtask badges */} @@ -148,7 +146,7 @@ export function TaskCard({ const isDueSoon = diffDays <= 2 && !isOverdue; return ( ๐Ÿ“… {isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`} @@ -159,8 +157,8 @@ export function TaskCard({ const total = task.subtasks.length; const pct = Math.round((done / total) * 100); return ( - - + + {done}/{total} @@ -170,23 +168,22 @@ export function TaskCard({ - {/* Expand chevron - always visible */} -
+ {/* Expand chevron */} +
- {/* Action buttons row - hidden on mobile, shown on sm+ */} -
e.stopPropagation()}> - {/* Reorder buttons for queued tasks */} + {/* Action buttons row */} +
e.stopPropagation()}> {task.status === "queued" && (
)} - {/* Quick status actions */} {actions.slice(0, 2).map((action) => ( diff --git a/frontend/src/components/TaskDetailPanel.tsx b/frontend/src/components/TaskDetailPanel.tsx index 6ab18f7..f74f449 100644 --- a/frontend/src/components/TaskDetailPanel.tsx +++ b/frontend/src/components/TaskDetailPanel.tsx @@ -18,11 +18,11 @@ const priorityIcons: Record = { }; const statusColors: Record = { - active: "bg-amber-100 text-amber-800 border-amber-300", - queued: "bg-blue-100 text-blue-800 border-blue-300", - blocked: "bg-red-100 text-red-800 border-red-300", - completed: "bg-green-100 text-green-800 border-green-300", - cancelled: "bg-gray-100 text-gray-600 border-gray-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 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-300 dark:border-blue-700", + 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 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-300 dark:border-green-700", + 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 = { @@ -34,33 +34,33 @@ const statusIcons: Record = { }; const sourceColors: Record = { - donovan: "bg-purple-100 text-purple-800", - david: "bg-green-100 text-green-800", - hammer: "bg-yellow-100 text-yellow-800", - heartbeat: "bg-pink-100 text-pink-800", - cron: "bg-indigo-100 text-indigo-800", - other: "bg-gray-100 text-gray-800", + donovan: "bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300", + david: "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300", + hammer: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300", + heartbeat: "bg-pink-100 dark:bg-pink-900/30 text-pink-800 dark:text-pink-300", + cron: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-300", + other: "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300", }; const statusActions: Record = { active: [ - { label: "โธ Pause", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }, - { label: "๐Ÿšซ Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" }, - { label: "โœ… Complete", next: "completed", color: "bg-green-50 text-green-700 border-green-200 hover:bg-green-100" }, - { label: "โŒ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-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 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 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 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: [ - { label: "โ–ถ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" }, - { label: "๐Ÿšซ Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" }, - { label: "โŒ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-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 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 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: [ - { label: "โ–ถ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" }, - { label: "๐Ÿ“‹ Queue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }, - { label: "โŒ Cancel", next: "cancelled", color: "bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-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 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 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" }], - cancelled: [{ 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 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"]; @@ -119,7 +119,7 @@ function ElapsedTimer({ since }: { since: string }) { }, [since]); return ( - {elapsed} + {elapsed} ); } @@ -149,11 +149,11 @@ function EditableText({ return (
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" > - {value || {placeholder}} - โœ๏ธ + {value || {placeholder}} + โœ๏ธ
); } @@ -166,7 +166,7 @@ function EditableText({ onChange={(e) => onChange(e.target.value)} onBlur={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} /> ); @@ -182,7 +182,7 @@ function EditableText({ if (e.key === "Enter") 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 ( -
+
{displayId && ( <> - {displayId} + {displayId} - | + | )} - {id} + {id}
{hasToken ? ( ) : ( -

{task.title}

+

{task.title}

)}
- {/* Close X button - hidden on mobile (use Back instead) */}
) : ( - - {task.dueDate ? new Date(task.dueDate).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }) : No due date} + + {task.dueDate ? new Date(task.dueDate).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" }) : No due date} )}
{/* Description */} -
-

Description

+
+

Description

{hasToken ? ( ) : ( -

- {task.description || No description} +

+ {task.description || No description}

)}
{/* Time Info */} -
-

Timeline

+
+

Timeline

- Created - {formatDate(task.createdAt)} ({timeAgo(task.createdAt)}) + Created + {formatDate(task.createdAt)} ({timeAgo(task.createdAt)})
{task.updatedAt !== task.createdAt && (
- Updated - {formatDate(task.updatedAt)} ({timeAgo(task.updatedAt)}) + Updated + {formatDate(task.updatedAt)} ({timeAgo(task.updatedAt)})
)} {task.completedAt && (
- Completed - {formatDate(task.completedAt)} + Completed + {formatDate(task.completedAt)}
)} {isActive && ( -
- โฑ Running for +
+ โฑ Running for
)} @@ -637,19 +627,18 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
{/* Subtasks */} -
-

+
+

Subtasks {task.subtasks?.length > 0 && ( - + ({task.subtasks.filter(s => s.completed).length}/{task.subtasks.length}) )}

- {/* Subtask progress bar */} {task.subtasks?.length > 0 && (
-
+
s.completed).length / task.subtasks.length) * 100}%` }} @@ -658,7 +647,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
)} - {/* Subtask list */} {task.subtasks?.length > 0 && (
{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 ${ subtask.completed ? "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 && ( @@ -684,7 +672,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, )} - + {subtask.title} {hasToken && ( @@ -697,7 +685,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, 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" > @@ -710,7 +698,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
)} - {/* Add subtask input */} {hasToken && (
setNewSubtaskTitle(e.target.value)} 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) => { if (e.key === "Enter" && newSubtaskTitle.trim()) { setAddingSubtask(true); @@ -760,13 +747,12 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, {/* Progress Notes */}
-

+

Progress Notes {task.progressNotes?.length > 0 && ( - ({task.progressNotes.length}) + ({task.progressNotes.length}) )}

- {/* Add note input */} {hasToken && (
@@ -775,7 +761,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, onChange={(e) => setNoteText(e.target.value)} placeholder="Add a progress note..." 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) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); @@ -810,12 +796,12 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, {addingNote ? "..." : "Add"}
-

โŒ˜+Enter to submit

+

โŒ˜+Enter to submit

)} {!task.progressNotes || task.progressNotes.length === 0 ? ( -
+
No progress notes yet
) : ( @@ -825,24 +811,21 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, .reverse() .map((note, i) => (
- {/* Timeline line */} {i < task.progressNotes.length - 1 && ( -
+
)} - {/* Timeline dot */}
- {/* Content */}
-

{note.note}

-

{formatTimestamp(note.timestamp)} ยท {timeAgo(note.timestamp)}

+

{note.note}

+

{formatTimestamp(note.timestamp)} ยท {timeAgo(note.timestamp)}

))} @@ -853,20 +836,20 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, {/* Save / Cancel Bar */} {hasToken && isDirty && ( -
- Unsaved changes +
+ Unsaved changes
@@ -876,8 +859,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, {/* Actions Footer */} {hasToken && actions.length > 0 && ( -
-

Actions

+
+

Actions

{actions.map((action) => ( ) : ( -
- Delete this task? +
+ Delete this task? @@ -922,7 +905,6 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
)} - {/* Task ID - click to copy */}
diff --git a/frontend/src/hooks/useTheme.tsx b/frontend/src/hooks/useTheme.tsx new file mode 100644 index 0000000..7a08b63 --- /dev/null +++ b/frontend/src/hooks/useTheme.tsx @@ -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({ + 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(() => { + 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 + 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 ( + + {children} + + ); +} + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index fb4e853..4a846dc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,7 @@ @import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); + @keyframes slide-in-right { from { transform: translateX(100%); diff --git a/frontend/src/pages/ActivityPage.tsx b/frontend/src/pages/ActivityPage.tsx index ebde861..653f140 100644 --- a/frontend/src/pages/ActivityPage.tsx +++ b/frontend/src/pages/ActivityPage.tsx @@ -50,7 +50,6 @@ export function ActivityPage() { return items; }, [tasks]); - // Group by day const groupedActivity = useMemo(() => { const filtered = filter === "all" @@ -77,7 +76,7 @@ export function ActivityPage() { if (loading && tasks.length === 0) { return ( -
+
Loading activity...
); @@ -85,18 +84,18 @@ export function ActivityPage() { return (
-
+
-

๐Ÿ“ Activity Log

-

+

๐Ÿ“ Activity Log

+

{allActivity.length} updates across {tasks.length} tasks

setEditName(e.target.value)} onBlur={commitRename} @@ -125,7 +125,7 @@ function ThreadList({ /> ) : (
{ e.stopPropagation(); startRename(thread.sessionKey, thread.name); @@ -158,13 +158,13 @@ function ThreadList({ )} {/* Gateway sessions browser */} -
+
{showGatewaySessions && ( -
+
{loadingGatewaySessions ? ( -
Loading sessions...
+
Loading sessions...
) : !gatewaySessions || gatewaySessions.length === 0 ? ( -
No sessions found
+
No sessions found
) : ( gatewaySessions.filter(s => !localSessionKeys.has(s.sessionKey)).map((session) => (
onSelect(session.sessionKey)} >
{session.channel === "telegram" ? "๐Ÿ“ฑ" : session.kind === "cron" ? "โฐ" : "๐Ÿ’ฌ"} - + {session.sessionKey}
@@ -233,7 +233,7 @@ function MessageBubble({ msg }: { msg: ChatMessage }) { if (isSystem) { return (
- + {msg.content}
@@ -255,7 +255,7 @@ function MessageBubble({ msg }: { msg: ChatMessage }) { className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${ isUser ? "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 ? ( @@ -284,7 +284,7 @@ function ThinkingIndicator() {
๐Ÿ”จ
-
+
@@ -352,9 +352,9 @@ function ChatArea({ {/* Messages */}
{loading ? ( -
Loading messages...
+
Loading messages...
) : messages.length === 0 ? ( -
+
๐Ÿ”จ

Send a message to start chatting with Hammer

@@ -373,7 +373,7 @@ function ChatArea({
{/* Input */} -
+
{connectionState === "disconnected" && (
@@ -395,7 +395,7 @@ function ChatArea({ placeholder={connected ? "Type a message..." : "Connecting..."} disabled={!connected} 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" }} /> {streaming ? ( @@ -653,19 +653,19 @@ export function ChatPage() { return (
{/* Page Header */} -
+
-

Chat

+

Chat

) : ( -
+
๐Ÿ’ฌ

Select or create a thread

diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index d6b9666..cd0bd91 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -28,7 +28,6 @@ function timeAgo(dateStr: string): string { } function RecentActivity({ tasks }: { tasks: Task[] }) { - // Gather all progress notes with task context, sorted by timestamp desc const recentNotes = useMemo(() => { const notes: { task: Task; note: ProgressNote }[] = []; for (const task of tasks) { @@ -44,7 +43,7 @@ function RecentActivity({ tasks }: { tasks: Task[] }) { if (recentNotes.length === 0) { return ( -
+
No recent activity
); @@ -54,18 +53,18 @@ function RecentActivity({ tasks }: { tasks: Task[] }) {
{recentNotes.map((item, i) => (
-
+
๐Ÿ”จ
- + HQ-{item.task.taskNumber} - {timeAgo(item.note.timestamp)} + {timeAgo(item.note.timestamp)}
-

{item.note.note}

-

{item.task.title}

+

{item.note.note}

+

{item.task.title}

))} @@ -108,7 +107,7 @@ export function DashboardPage() { if (loading && tasks.length === 0) { return ( -
+
Loading dashboard...
); @@ -116,53 +115,53 @@ export function DashboardPage() { return (
-
+
-

๐Ÿ”จ Dashboard

-

Overview of Hammer's work

+

๐Ÿ”จ Dashboard

+

Overview of Hammer's work

{/* Stats Grid */}
- - - - + + + +
{/* Currently Working On */} -
-
-

โšก Currently Working On

- +
+
+

โšก Currently Working On

+ View Queue โ†’
{activeTasks.length === 0 ? ( -
+
Hammer is idle โ€” no active tasks
) : (
{activeTasks.map((task) => ( -
-
+
+
- HQ-{task.taskNumber} - {task.priority} + HQ-{task.taskNumber} + {task.priority} {task.projectId && projectMap[task.projectId] && ( - ๐Ÿ“ {projectMap[task.projectId]} + ๐Ÿ“ {projectMap[task.projectId]} )} {task.assigneeName && ( - ๐Ÿ‘ค {task.assigneeName} + ๐Ÿ‘ค {task.assigneeName} )} {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 isOverdue = diffMs < 0; return isOverdue || diffDays <= 2 ? ( - + ๐Ÿ“… {isOverdue ? "overdue" : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`} ) : null; })()}
-

{task.title}

+

{task.title}

{task.subtasks?.length > 0 && (
-
+
s.completed).length / task.subtasks.length) * 100}%` }} />
- {task.subtasks.filter(s => s.completed).length}/{task.subtasks.length} + {task.subtasks.filter(s => s.completed).length}/{task.subtasks.length}
)} {task.progressNotes?.length > 0 && ( -

+

Latest: {task.progressNotes[task.progressNotes.length - 1].note}

)} @@ -198,16 +197,16 @@ export function DashboardPage() { {/* Up Next */} {upNext.length > 0 && ( -
-

Up Next

+
+

Up Next

{upNext.map((task, i) => ( - - {i + 1}. - HQ-{task.taskNumber} - {task.title} + + {i + 1}. + HQ-{task.taskNumber} + {task.title} {task.priority === "high" || task.priority === "critical" ? ( - + {task.priority} ) : null} @@ -220,9 +219,9 @@ export function DashboardPage() {
{/* Recent Activity */} -
-
-

๐Ÿ“ Recent Activity

+
+
+

๐Ÿ“ Recent Activity

@@ -232,21 +231,21 @@ export function DashboardPage() { {/* Recently Completed */} {recentlyCompleted.length > 0 && ( -
-
-

โœ… Recently Completed

+
+
+

โœ… Recently Completed

{recentlyCompleted.map((task) => ( - +
- HQ-{task.taskNumber} + HQ-{task.taskNumber} {task.completedAt && ( - {timeAgo(task.completedAt)} + {timeAgo(task.completedAt)} )}
-

{task.title}

+

{task.title}

))}
diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index ed826c2..25671b9 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -141,12 +141,12 @@ export function QueuePage() { return (
{/* Page Header */} -
+
-

Task Queue

-

+

Task Queue

+

Manage what Hammer is working on {filteredTasks.length !== tasks.length && ( ยท {filteredTasks.length} of {tasks.length} shown @@ -155,10 +155,10 @@ export function QueuePage() {

{/* View toggle */} -
+
- +
{loading && ( -
Loading tasks...
+
Loading tasks...
)} {error && ( -
+
{error}
)} @@ -276,11 +276,11 @@ export function QueuePage() { {/* Active Task */} {showSection("active") && (
-

+

โšก Currently Working On

{activeTasks.length === 0 ? ( -
+
No active task โ€” Hammer is idle
) : ( @@ -303,7 +303,7 @@ export function QueuePage() { {/* Blocked */} {showSection("blocked") && blockedTasks.length > 0 && (
-

+

๐Ÿšซ Blocked ({blockedTasks.length})

@@ -323,11 +323,11 @@ export function QueuePage() { {/* Queue */} {showSection("queued") && (
-

+

๐Ÿ“‹ Queue ({queuedTasks.length})

{queuedTasks.length === 0 ? ( -
+
Queue is empty
) : ( @@ -355,7 +355,7 @@ export function QueuePage() {
{filterStatus ? ( <> -

+

{filterStatus === "completed" ? "โœ…" : "โŒ"} {filterStatus.charAt(0).toUpperCase() + filterStatus.slice(1)} ({completedTasks.length})

@@ -374,7 +374,7 @@ export function QueuePage() { <> diff --git a/frontend/src/pages/TaskPage.tsx b/frontend/src/pages/TaskPage.tsx index 6027548..487a1c7 100644 --- a/frontend/src/pages/TaskPage.tsx +++ b/frontend/src/pages/TaskPage.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useCallback } from "react"; 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 { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api"; import { useToast } from "../components/Toast"; @@ -12,11 +14,11 @@ const priorityColors: Record = { }; const statusColors: Record = { - active: "bg-amber-100 text-amber-800 border-amber-300", - queued: "bg-blue-100 text-blue-800 border-blue-300", - blocked: "bg-red-100 text-red-800 border-red-300", - completed: "bg-green-100 text-green-800 border-green-300", - cancelled: "bg-gray-100 text-gray-600 border-gray-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 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-300 dark:border-blue-700", + 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 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-300 dark:border-green-700", + 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 = { @@ -29,20 +31,20 @@ const statusIcons: Record = { const statusActions: Record = { active: [ - { label: "โธ Pause", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100" }, - { label: "๐Ÿšซ Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-100" }, - { label: "โœ… Complete", next: "completed", color: "bg-green-50 text-green-700 border-green-200 hover:bg-green-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 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 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: [ - { label: "โ–ถ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" }, - { label: "๐Ÿšซ Block", next: "blocked", color: "bg-red-50 text-red-700 border-red-200 hover:bg-red-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 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: [ - { label: "โ–ถ Activate", next: "active", color: "bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100" }, - { label: "๐Ÿ“‹ Queue", next: "queued", color: "bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-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 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" }], - cancelled: [{ 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 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 { @@ -63,6 +65,9 @@ function timeAgo(dateStr: string): string { 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() { const { taskRef } = useParams<{ taskRef: string }>(); const [task, setTask] = useState(null); @@ -76,6 +81,14 @@ export function TaskPage() { const [saving, setSaving] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = 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 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 () => { if (!task) return; setDeleting(true); @@ -138,7 +183,7 @@ export function TaskPage() { if (loading) { return ( -
+
Loading task...
); @@ -149,8 +194,8 @@ export function TaskPage() {
๐Ÿ˜• -

{error || "Task not found"}

- +

{error || "Task not found"}

+ โ† Back to Queue
@@ -168,14 +213,18 @@ export function TaskPage() { return (
{/* Header */} -
+
- + โ† Queue - / - + / + HQ-{task.taskNumber}
@@ -195,15 +244,51 @@ export function TaskPage() { {task.priority} {project && ( - + ๐Ÿ“ {project.name} )}
-

{task.title}

+ {editingTitle ? ( +
+ 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} + /> + + +
+ ) : ( +

{ setTitleDraft(task.title); setEditingTitle(true); }} + title="Click to edit title" + > + {task.title} + โœ๏ธ +

+ )}
{/* Status Actions */} -
+
{actions.map((action) => ( + )} +
+ {editingDescription ? ( +
+