Initial scaffold: Hammer Queue task dashboard
- Backend: Elysia + Bun + Drizzle ORM + PostgreSQL - Frontend: React + Vite + TypeScript + Tailwind CSS - Task CRUD API with bearer token auth for writes - Public read-only dashboard with auto-refresh - Task states: active, queued, blocked, completed, cancelled - Reorder support for queue management - Progress notes per task - Docker Compose for local dev and Dokploy deployment
This commit is contained in:
267
frontend/src/App.tsx
Normal file
267
frontend/src/App.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { TaskCard } from "./components/TaskCard";
|
||||
import { CreateTaskModal } from "./components/CreateTaskModal";
|
||||
import { updateTask, reorderTasks, createTask } from "./lib/api";
|
||||
import type { TaskStatus } from "./lib/types";
|
||||
|
||||
// Token stored in localStorage for dashboard admin operations
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("hammer-queue-token") || "";
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { tasks, loading, error, refresh } = useTasks(5000);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState("");
|
||||
const [showTokenInput, setShowTokenInput] = useState(false);
|
||||
|
||||
const token = getToken();
|
||||
const hasToken = !!token;
|
||||
|
||||
const activeTasks = useMemo(() => tasks.filter((t) => t.status === "active"), [tasks]);
|
||||
const queuedTasks = useMemo(() => tasks.filter((t) => t.status === "queued"), [tasks]);
|
||||
const blockedTasks = useMemo(() => tasks.filter((t) => t.status === "blocked"), [tasks]);
|
||||
const completedTasks = useMemo(
|
||||
() => tasks.filter((t) => t.status === "completed" || t.status === "cancelled"),
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const handleStatusChange = async (id: string, status: TaskStatus) => {
|
||||
if (!hasToken) {
|
||||
setShowTokenInput(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateTask(id, { status }, token);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
alert("Failed to update task. Check your token.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveUp = async (index: number) => {
|
||||
if (index === 0 || !hasToken) return;
|
||||
const ids = queuedTasks.map((t) => t.id);
|
||||
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||
await reorderTasks(ids, token);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleMoveDown = async (index: number) => {
|
||||
if (index >= queuedTasks.length - 1 || !hasToken) return;
|
||||
const ids = queuedTasks.map((t) => t.id);
|
||||
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||
await reorderTasks(ids, token);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleCreate = async (task: {
|
||||
title: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
priority?: string;
|
||||
}) => {
|
||||
if (!hasToken) {
|
||||
setShowTokenInput(true);
|
||||
return;
|
||||
}
|
||||
await createTask(task, token);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleSetToken = () => {
|
||||
localStorage.setItem("hammer-queue-token", tokenInput);
|
||||
setTokenInput("");
|
||||
setShowTokenInput(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🔨</span>
|
||||
<h1 className="text-xl font-bold text-gray-900">Hammer Queue</h1>
|
||||
<span className="text-xs text-gray-400 mt-1">Task Dashboard</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasToken ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="text-sm bg-amber-500 text-white px-3 py-1.5 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
>
|
||||
+ New Task
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem("hammer-queue-token");
|
||||
refresh();
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-2 py-1"
|
||||
title="Log out"
|
||||
>
|
||||
🔓
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowTokenInput(true)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 px-3 py-1.5 border rounded-lg"
|
||||
>
|
||||
🔑 Set Token
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Token input modal */}
|
||||
{showTokenInput && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-sm mx-4">
|
||||
<h2 className="text-lg font-bold mb-3">API Token</h2>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Bearer token..."
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSetToken()}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSetToken}
|
||||
className="flex-1 bg-amber-500 text-white rounded-lg py-2 text-sm font-medium hover:bg-amber-600"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTokenInput(false)}
|
||||
className="px-4 py-2 text-sm text-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateTaskModal
|
||||
open={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 py-6 space-y-6">
|
||||
{loading && (
|
||||
<div className="text-center text-gray-400 py-12">Loading tasks...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Task */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
⚡ Currently Working On
|
||||
</h2>
|
||||
{activeTasks.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center text-gray-400">
|
||||
No active task — Hammer is idle
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
isActive
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Blocked */}
|
||||
{blockedTasks.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
🚫 Blocked ({blockedTasks.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{blockedTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Queue */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
📋 Queue ({queuedTasks.length})
|
||||
</h2>
|
||||
{queuedTasks.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center text-gray-400">
|
||||
Queue is empty
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queuedTasks.map((task, i) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
onMoveUp={() => handleMoveUp(i)}
|
||||
onMoveDown={() => handleMoveDown(i)}
|
||||
isFirst={i === 0}
|
||||
isLast={i === queuedTasks.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Completed */}
|
||||
<section>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{showCompleted ? "▾" : "▸"} Completed / Cancelled ({completedTasks.length})
|
||||
</button>
|
||||
{showCompleted && (
|
||||
<div className="space-y-2 opacity-60">
|
||||
{completedTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="text-center text-xs text-gray-300 py-4">
|
||||
Hammer Queue v0.1 · Auto-refreshes every 5s
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
107
frontend/src/components/CreateTaskModal.tsx
Normal file
107
frontend/src/components/CreateTaskModal.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (task: {
|
||||
title: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
priority?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [source, setSource] = useState("donovan");
|
||||
const [priority, setPriority] = useState("medium");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
onCreate({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
source,
|
||||
priority,
|
||||
});
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setSource("donovan");
|
||||
setPriority("medium");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-lg font-bold mb-4">New Task</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Task title..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
autoFocus
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Description / context (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 block mb-1">Source</label>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="donovan">Donovan</option>
|
||||
<option value="david">David</option>
|
||||
<option value="hammer">Hammer</option>
|
||||
<option value="heartbeat">Heartbeat</option>
|
||||
<option value="cron">Cron</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 block mb-1">Priority</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-amber-500 text-white rounded-lg py-2 text-sm font-medium hover:bg-amber-600 transition"
|
||||
>
|
||||
Create Task
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
frontend/src/components/TaskCard.tsx
Normal file
169
frontend/src/components/TaskCard.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { Task, TaskStatus, TaskPriority } from "../lib/types";
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
critical: "bg-red-500 text-white",
|
||||
high: "bg-orange-500 text-white",
|
||||
medium: "bg-blue-500 text-white",
|
||||
low: "bg-gray-400 text-white",
|
||||
};
|
||||
|
||||
const sourceColors: Record<string, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus }[]> = {
|
||||
active: [
|
||||
{ label: "⏸ Pause", next: "queued" },
|
||||
{ label: "🚫 Block", next: "blocked" },
|
||||
{ label: "✅ Complete", next: "completed" },
|
||||
{ label: "❌ Cancel", next: "cancelled" },
|
||||
],
|
||||
queued: [
|
||||
{ label: "▶ Activate", next: "active" },
|
||||
{ label: "🚫 Block", next: "blocked" },
|
||||
{ label: "❌ Cancel", next: "cancelled" },
|
||||
],
|
||||
blocked: [
|
||||
{ label: "▶ Activate", next: "active" },
|
||||
{ label: "📋 Queue", next: "queued" },
|
||||
{ label: "❌ Cancel", next: "cancelled" },
|
||||
],
|
||||
completed: [{ label: "🔄 Requeue", next: "queued" }],
|
||||
cancelled: [{ label: "🔄 Requeue", next: "queued" }],
|
||||
};
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onStatusChange: (id: string, status: TaskStatus) => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
task,
|
||||
onStatusChange,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst,
|
||||
isLast,
|
||||
isActive,
|
||||
}: TaskCardProps) {
|
||||
const actions = statusActions[task.status] || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-4 transition-all ${
|
||||
isActive
|
||||
? "border-amber-400 bg-amber-50 shadow-lg shadow-amber-100"
|
||||
: "border-gray-200 bg-white shadow-sm hover:shadow-md"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center text-xs font-bold text-amber-700 bg-amber-200 px-2 py-0.5 rounded-full animate-pulse">
|
||||
⚡ ACTIVE
|
||||
</span>
|
||||
)}
|
||||
<h3 className="font-semibold text-gray-900 truncate">{task.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source]}`}>
|
||||
{task.source}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
created {timeAgo(task.createdAt)}
|
||||
</span>
|
||||
{task.updatedAt !== task.createdAt && (
|
||||
<span className="text-xs text-gray-400">
|
||||
· updated {timeAgo(task.updatedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-600 mb-2 line-clamp-2">{task.description}</p>
|
||||
)}
|
||||
|
||||
{task.progressNotes && task.progressNotes.length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
|
||||
{task.progressNotes.length} progress note{task.progressNotes.length !== 1 ? "s" : ""}
|
||||
</summary>
|
||||
<div className="mt-1 space-y-1 pl-2 border-l-2 border-gray-200">
|
||||
{task.progressNotes
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((note, i) => (
|
||||
<div key={i} className="text-xs text-gray-600">
|
||||
<span className="text-gray-400">{timeAgo(note.timestamp)}</span>{" "}
|
||||
— {note.note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 shrink-0">
|
||||
{/* Reorder buttons for queued tasks */}
|
||||
{task.status === "queued" && (
|
||||
<div className="flex gap-1 mb-1">
|
||||
<button
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className="text-xs px-1.5 py-0.5 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move up"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className="text-xs px-1.5 py-0.5 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status actions */}
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.next}
|
||||
onClick={() => onStatusChange(task.id, action.next)}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-200 hover:bg-gray-50 whitespace-nowrap text-left"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/hooks/useTasks.ts
Normal file
29
frontend/src/hooks/useTasks.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Task } from "../lib/types";
|
||||
import { fetchTasks } from "../lib/api";
|
||||
|
||||
export function useTasks(pollInterval = 5000) {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchTasks();
|
||||
setTasks(data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const interval = setInterval(refresh, pollInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh, pollInterval]);
|
||||
|
||||
return { tasks, loading, error, refresh };
|
||||
}
|
||||
1
frontend/src/index.css
Normal file
1
frontend/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
62
frontend/src/lib/api.ts
Normal file
62
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Task } from "./types";
|
||||
|
||||
const BASE = "/api/tasks";
|
||||
|
||||
export async function fetchTasks(): Promise<Task[]> {
|
||||
const res = await fetch(BASE);
|
||||
if (!res.ok) throw new Error("Failed to fetch tasks");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
id: string,
|
||||
updates: Record<string, any>,
|
||||
token: string
|
||||
): Promise<Task> {
|
||||
const res = await fetch(`${BASE}/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update task");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function reorderTasks(ids: string[], token: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/reorder`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to reorder tasks");
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
task: { title: string; description?: string; source?: string; priority?: string; status?: string },
|
||||
token: string
|
||||
): Promise<Task> {
|
||||
const res = await fetch(BASE, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(task),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create task");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteTask(id: string, token: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete task");
|
||||
}
|
||||
22
frontend/src/lib/types.ts
Normal file
22
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type TaskStatus = "active" | "queued" | "blocked" | "completed" | "cancelled";
|
||||
export type TaskPriority = "critical" | "high" | "medium" | "low";
|
||||
export type TaskSource = "donovan" | "david" | "hammer" | "heartbeat" | "cron" | "other";
|
||||
|
||||
export interface ProgressNote {
|
||||
timestamp: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
source: TaskSource;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
position: number;
|
||||
progressNotes: ProgressNote[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
Reference in New Issue
Block a user