feat: Hammer Dashboard with sidebar navigation (HQ-21)
- Add React Router with sidebar layout (DashboardLayout) - Queue is now a routed page at /queue - Chat placeholder page at /chat - Admin page accessible from sidebar - Dark sidebar with amber accent for active nav - Updated CORS and auth to support dash.donovankelly.xyz - Renamed to Hammer Dashboard
This commit is contained in:
@@ -52,7 +52,7 @@ ensureAdmin().catch(console.error);
|
|||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(
|
.use(
|
||||||
cors({
|
cors({
|
||||||
origin: ["https://queue.donovankelly.xyz", "http://localhost:5173"],
|
origin: ["https://dash.donovankelly.xyz", "https://queue.donovankelly.xyz", "http://localhost:5173"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
|
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
|
||||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustedOrigins: [
|
trustedOrigins: [
|
||||||
|
"https://dash.donovankelly.xyz",
|
||||||
"https://queue.donovankelly.xyz",
|
"https://queue.donovankelly.xyz",
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ services:
|
|||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
API_BEARER_TOKEN: ${API_BEARER_TOKEN}
|
API_BEARER_TOKEN: ${API_BEARER_TOKEN}
|
||||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||||
BETTER_AUTH_URL: https://queue.donovankelly.xyz
|
BETTER_AUTH_URL: https://dash.donovankelly.xyz
|
||||||
COOKIE_DOMAIN: .donovankelly.xyz
|
COOKIE_DOMAIN: .donovankelly.xyz
|
||||||
CLAWDBOT_HOOK_URL: ${CLAWDBOT_HOOK_URL:-https://hooks.hammer.donovankelly.xyz/hooks/agent}
|
CLAWDBOT_HOOK_URL: ${CLAWDBOT_HOOK_URL:-https://hooks.hammer.donovankelly.xyz/hooks/agent}
|
||||||
CLAWDBOT_HOOK_TOKEN: ${CLAWDBOT_HOOK_TOKEN}
|
CLAWDBOT_HOOK_TOKEN: ${CLAWDBOT_HOOK_TOKEN}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -326,6 +327,8 @@
|
|||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
@@ -508,6 +511,10 @@
|
|||||||
|
|
||||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||||
|
|
||||||
|
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
|
||||||
|
|
||||||
|
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
|
||||||
|
|
||||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
|
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Hammer Queue</title>
|
<title>Hammer Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,254 +1,23 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useTasks } from "./hooks/useTasks";
|
import { DashboardLayout } from "./components/DashboardLayout";
|
||||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
import { QueuePage } from "./pages/QueuePage";
|
||||||
import { TaskCard } from "./components/TaskCard";
|
import { ChatPage } from "./pages/ChatPage";
|
||||||
import { TaskDetailPanel } from "./components/TaskDetailPanel";
|
|
||||||
import { CreateTaskModal } from "./components/CreateTaskModal";
|
|
||||||
import { AdminPage } from "./components/AdminPage";
|
import { AdminPage } from "./components/AdminPage";
|
||||||
import { LoginPage } from "./components/LoginPage";
|
import { LoginPage } from "./components/LoginPage";
|
||||||
import { useSession, signOut } from "./lib/auth-client";
|
import { useSession } from "./lib/auth-client";
|
||||||
import { updateTask, reorderTasks, createTask } from "./lib/api";
|
|
||||||
import type { TaskStatus } from "./lib/types";
|
|
||||||
|
|
||||||
function Dashboard() {
|
|
||||||
const { tasks, loading, error, refresh } = useTasks(5000);
|
|
||||||
const { user, isAdmin, isAuthenticated } = useCurrentUser();
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [showCompleted, setShowCompleted] = useState(false);
|
|
||||||
const [selectedTask, setSelectedTask] = useState<string | null>(null);
|
|
||||||
const [showAdmin, setShowAdmin] = useState(false);
|
|
||||||
|
|
||||||
const selectedTaskData = useMemo(() => {
|
|
||||||
if (!selectedTask) return null;
|
|
||||||
return tasks.find((t) => t.id === selectedTask) || null;
|
|
||||||
}, [tasks, selectedTask]);
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
try {
|
|
||||||
await updateTask(id, { status });
|
|
||||||
refresh();
|
|
||||||
} catch (e) {
|
|
||||||
alert("Failed to update task.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMoveUp = async (index: number) => {
|
|
||||||
if (index === 0) return;
|
|
||||||
const ids = queuedTasks.map((t) => t.id);
|
|
||||||
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
|
||||||
await reorderTasks(ids);
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMoveDown = async (index: number) => {
|
|
||||||
if (index >= queuedTasks.length - 1) return;
|
|
||||||
const ids = queuedTasks.map((t) => t.id);
|
|
||||||
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
|
||||||
await reorderTasks(ids);
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = async (task: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
source?: string;
|
|
||||||
priority?: string;
|
|
||||||
}) => {
|
|
||||||
await createTask(task);
|
|
||||||
refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await signOut();
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (showAdmin) {
|
|
||||||
return <AdminPage onBack={() => setShowAdmin(false)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function AuthenticatedApp() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<BrowserRouter>
|
||||||
{/* Header */}
|
<Routes>
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
<Route element={<DashboardLayout />}>
|
||||||
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
<Route path="/queue" element={<QueuePage />} />
|
||||||
<div className="flex items-center gap-2">
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
<span className="text-2xl">🔨</span>
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<h1 className="text-xl font-bold text-gray-900">Hammer Queue</h1>
|
<Route path="*" element={<Navigate to="/queue" replace />} />
|
||||||
<span className="text-xs text-gray-400 mt-1">Task Dashboard</span>
|
</Route>
|
||||||
</div>
|
</Routes>
|
||||||
<div className="flex items-center gap-3">
|
</BrowserRouter>
|
||||||
<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>
|
|
||||||
{isAdmin && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAdmin(true)}
|
|
||||||
className="text-xs text-purple-600 hover:text-purple-800 px-2 py-1 border border-purple-200 rounded-lg transition"
|
|
||||||
title="Admin panel"
|
|
||||||
>
|
|
||||||
⚙️ Admin
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
||||||
<span className="hidden sm:inline">{user?.email}</span>
|
|
||||||
{isAdmin && (
|
|
||||||
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full font-medium">
|
|
||||||
admin
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1 border border-gray-200 rounded-lg transition"
|
|
||||||
title="Sign out"
|
|
||||||
>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<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
|
|
||||||
onClick={() => setSelectedTask(task.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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}
|
|
||||||
onClick={() => setSelectedTask(task.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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}
|
|
||||||
onClick={() => setSelectedTask(task.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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}
|
|
||||||
onClick={() => setSelectedTask(task.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Task Detail Panel */}
|
|
||||||
{selectedTaskData && (
|
|
||||||
<TaskDetailPanel
|
|
||||||
task={selectedTaskData}
|
|
||||||
onClose={() => setSelectedTask(null)}
|
|
||||||
onStatusChange={(id, status) => {
|
|
||||||
handleStatusChange(id, status);
|
|
||||||
setSelectedTask(null);
|
|
||||||
}}
|
|
||||||
onTaskUpdated={refresh}
|
|
||||||
hasToken={isAuthenticated}
|
|
||||||
token=""
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="text-center text-xs text-gray-300 py-4">
|
|
||||||
Hammer Queue v0.2 · Auto-refreshes every 5s
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +36,7 @@ function App() {
|
|||||||
return <LoginPage onSuccess={() => window.location.reload()} />;
|
return <LoginPage onSuccess={() => window.location.reload()} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Dashboard />;
|
return <AuthenticatedApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { fetchUsers, updateUserRole, deleteUser } from "../lib/api";
|
import { fetchUsers, updateUserRole, deleteUser } from "../lib/api";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -9,7 +10,8 @@ interface User {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminPage({ onBack }: { onBack: () => void }) {
|
export function AdminPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -56,7 +58,7 @@ export function AdminPage({ onBack }: { onBack: () => void }) {
|
|||||||
<p className="text-sm text-gray-500 mt-1">Manage users and roles</p>
|
<p className="text-sm text-gray-500 mt-1">Manage users and roles</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={() => navigate("/queue")}
|
||||||
className="text-sm px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50 font-medium text-gray-600"
|
className="text-sm px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50 font-medium text-gray-600"
|
||||||
>
|
>
|
||||||
← Back to Queue
|
← Back to Queue
|
||||||
|
|||||||
98
frontend/src/components/DashboardLayout.tsx
Normal file
98
frontend/src/components/DashboardLayout.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||||
|
import { signOut } from "../lib/auth-client";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/queue", label: "Queue", icon: "📋" },
|
||||||
|
{ to: "/chat", label: "Chat", icon: "💬" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardLayout() {
|
||||||
|
const { user, isAdmin } = useCurrentUser();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await signOut();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-56 bg-gray-900 text-white flex flex-col fixed inset-y-0 left-0 z-50">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="px-4 py-5 border-b border-gray-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">🔨</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold leading-tight">Hammer</h1>
|
||||||
|
<span className="text-xs text-gray-400">Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-3 py-4 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition ${
|
||||||
|
isActive
|
||||||
|
? "bg-amber-500/20 text-amber-400"
|
||||||
|
: "text-gray-400 hover:text-white hover:bg-gray-800"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-lg">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
{isAdmin && (
|
||||||
|
<NavLink
|
||||||
|
to="/admin"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition ${
|
||||||
|
isActive
|
||||||
|
? "bg-purple-500/20 text-purple-400"
|
||||||
|
: "text-gray-400 hover:text-white hover:bg-gray-800"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-lg">⚙️</span>
|
||||||
|
Admin
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User */}
|
||||||
|
<div className="px-3 py-4 border-t border-gray-800">
|
||||||
|
<div className="flex items-center gap-2 px-3 mb-2">
|
||||||
|
<div className="w-7 h-7 bg-amber-500 rounded-full flex items-center justify-center text-xs font-bold text-white">
|
||||||
|
{user?.name?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || "?"}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-300 truncate">{user?.name || user?.email}</p>
|
||||||
|
{isAdmin && (
|
||||||
|
<span className="text-[10px] bg-purple-500/30 text-purple-300 px-1.5 py-0.5 rounded-full font-medium">
|
||||||
|
admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 ml-56">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/pages/ChatPage.tsx
Normal file
23
frontend/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export function ChatPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Page Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-4">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Chat</h1>
|
||||||
|
<p className="text-sm text-gray-400">Talk to Hammer directly</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-6">
|
||||||
|
<div className="border-2 border-dashed border-gray-200 rounded-lg p-12 text-center">
|
||||||
|
<span className="text-4xl mb-4 block">💬</span>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-600 mb-2">Chat coming soon</h2>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
You'll be able to chat with Hammer right here in the dashboard.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
frontend/src/pages/QueuePage.tsx
Normal file
208
frontend/src/pages/QueuePage.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { useTasks } from "../hooks/useTasks";
|
||||||
|
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||||
|
import { TaskCard } from "../components/TaskCard";
|
||||||
|
import { TaskDetailPanel } from "../components/TaskDetailPanel";
|
||||||
|
import { CreateTaskModal } from "../components/CreateTaskModal";
|
||||||
|
import { updateTask, reorderTasks, createTask } from "../lib/api";
|
||||||
|
import type { TaskStatus } from "../lib/types";
|
||||||
|
|
||||||
|
export function QueuePage() {
|
||||||
|
const { tasks, loading, error, refresh } = useTasks(5000);
|
||||||
|
const { isAuthenticated } = useCurrentUser();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
|
const [selectedTask, setSelectedTask] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedTaskData = useMemo(() => {
|
||||||
|
if (!selectedTask) return null;
|
||||||
|
return tasks.find((t) => t.id === selectedTask) || null;
|
||||||
|
}, [tasks, selectedTask]);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
try {
|
||||||
|
await updateTask(id, { status });
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to update task.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveUp = async (index: number) => {
|
||||||
|
if (index === 0) return;
|
||||||
|
const ids = queuedTasks.map((t) => t.id);
|
||||||
|
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||||
|
await reorderTasks(ids);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveDown = async (index: number) => {
|
||||||
|
if (index >= queuedTasks.length - 1) return;
|
||||||
|
const ids = queuedTasks.map((t) => t.id);
|
||||||
|
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||||
|
await reorderTasks(ids);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (task: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
source?: string;
|
||||||
|
priority?: string;
|
||||||
|
}) => {
|
||||||
|
await createTask(task);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Page Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-30">
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Task Queue</h1>
|
||||||
|
<p className="text-sm text-gray-400">Manage what Hammer is working on</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="text-sm bg-amber-500 text-white px-4 py-2 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||||
|
>
|
||||||
|
+ New Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<CreateTaskModal
|
||||||
|
open={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-6 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
|
||||||
|
onClick={() => setSelectedTask(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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}
|
||||||
|
onClick={() => setSelectedTask(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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}
|
||||||
|
onClick={() => setSelectedTask(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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}
|
||||||
|
onClick={() => setSelectedTask(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Detail Panel */}
|
||||||
|
{selectedTaskData && (
|
||||||
|
<TaskDetailPanel
|
||||||
|
task={selectedTaskData}
|
||||||
|
onClose={() => setSelectedTask(null)}
|
||||||
|
onStatusChange={(id, status) => {
|
||||||
|
handleStatusChange(id, status);
|
||||||
|
setSelectedTask(null);
|
||||||
|
}}
|
||||||
|
onTaskUpdated={refresh}
|
||||||
|
hasToken={isAuthenticated}
|
||||||
|
token=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user