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;
|
||||
Reference in New Issue
Block a user