feat: merge HAI-002 React dashboard + add merge flow
This commit is contained in:
63
packages/dashboard/app/App.tsx
Normal file
63
packages/dashboard/app/App.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { TaskDetail } from "@hai/core";
|
||||
import { Header } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
import { CreateTaskModal } from "./components/CreateTaskModal";
|
||||
import { TaskDetailModal } from "./components/TaskDetailModal";
|
||||
import { ToastContainer } from "./components/ToastContainer";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
|
||||
function AppInner() {
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const { tasks, createTask, moveTask, deleteTask, mergeTask } = useTasks();
|
||||
const { toasts, addToast, removeToast } = useToast();
|
||||
|
||||
const handleCreateOpen = useCallback(() => setCreateModalOpen(true), []);
|
||||
const handleCreateClose = useCallback(() => setCreateModalOpen(false), []);
|
||||
|
||||
const handleDetailOpen = useCallback((task: TaskDetail) => {
|
||||
setDetailTask(task);
|
||||
}, []);
|
||||
|
||||
const handleDetailClose = useCallback(() => setDetailTask(null), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header onNewTask={handleCreateOpen} />
|
||||
<Board
|
||||
tasks={tasks}
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
/>
|
||||
{createModalOpen && (
|
||||
<CreateTaskModal
|
||||
onClose={handleCreateClose}
|
||||
onCreateTask={createTask}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{detailTask && (
|
||||
<TaskDetailModal
|
||||
task={detailTask}
|
||||
onClose={handleDetailClose}
|
||||
onMoveTask={moveTask}
|
||||
onDeleteTask={deleteTask}
|
||||
onMergeTask={mergeTask}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AppInner />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
41
packages/dashboard/app/api.ts
Normal file
41
packages/dashboard/app/api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Task, TaskDetail, TaskCreateInput, Column, MergeResult } from "@hai/core";
|
||||
|
||||
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || "Request failed");
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function fetchTasks(): Promise<Task[]> {
|
||||
return api<Task[]>("/tasks");
|
||||
}
|
||||
|
||||
export function fetchTaskDetail(id: string): Promise<TaskDetail> {
|
||||
return api<TaskDetail>(`/tasks/${id}`);
|
||||
}
|
||||
|
||||
export function createTask(input: TaskCreateInput): Promise<Task> {
|
||||
return api<Task>("/tasks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function moveTask(id: string, column: Column): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ column }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTask(id: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function mergeTask(id: string): Promise<MergeResult> {
|
||||
return api<MergeResult>(`/tasks/${id}/merge`, { method: "POST" });
|
||||
}
|
||||
28
packages/dashboard/app/components/Board.tsx
Normal file
28
packages/dashboard/app/components/Board.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Task, TaskDetail, Column as ColumnType } from "@hai/core";
|
||||
import { COLUMNS } from "@hai/core";
|
||||
import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function Board({ tasks, onMoveTask, onOpenDetail, addToast }: BoardProps) {
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={tasks.filter((t) => t.column === col)}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
69
packages/dashboard/app/components/Column.tsx
Normal file
69
packages/dashboard/app/components/Column.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Task, TaskDetail, Column as ColumnType } from "@hai/core";
|
||||
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@hai/core";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface ColumnProps {
|
||||
column: ColumnType;
|
||||
tasks: Task[];
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, onMoveTask, onOpenDetail, addToast }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
if (!el.contains(e.relatedTarget as Node)) {
|
||||
setDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const taskId = e.dataTransfer.getData("text/plain");
|
||||
if (!taskId) return;
|
||||
|
||||
try {
|
||||
await onMoveTask(taskId, column);
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [column, onMoveTask, addToast]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`column${dragOver ? " drag-over" : ""}`}
|
||||
data-column={column}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="column-header">
|
||||
<div className={`column-dot dot-${column}`} />
|
||||
<h2>{COLUMN_LABELS[column]}</h2>
|
||||
<span className="column-count">{tasks.length}</span>
|
||||
</div>
|
||||
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
|
||||
<div className="column-body">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
packages/dashboard/app/components/CreateTaskModal.tsx
Normal file
120
packages/dashboard/app/components/CreateTaskModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { Task, TaskCreateInput } from "@hai/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
onClose: () => void;
|
||||
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function CreateTaskModal({ onClose, onCreateTask, addToast }: CreateTaskModalProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [deps, setDeps] = useState("");
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => titleRef.current?.focus(), 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) return;
|
||||
|
||||
const dependencies = deps
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
try {
|
||||
const task = await onCreateTask({
|
||||
title: trimmedTitle,
|
||||
description: description.trim() || undefined,
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
});
|
||||
addToast(`Created ${task.id}`, "success");
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
},
|
||||
[title, description, deps, onCreateTask, addToast, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h3>New Task</h3>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="task-title">Title</label>
|
||||
<input
|
||||
ref={titleRef}
|
||||
type="text"
|
||||
id="task-title"
|
||||
placeholder="What needs to be done?"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="task-desc">
|
||||
Description <span className="optional">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="task-desc"
|
||||
rows={4}
|
||||
placeholder="Add context, requirements, or rough notes..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="task-deps">
|
||||
Dependencies <span className="optional">(comma-separated IDs)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="task-deps"
|
||||
placeholder="HAI-001, HAI-002"
|
||||
value={deps}
|
||||
onChange={(e) => setDeps(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create in Triage
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
packages/dashboard/app/components/Header.tsx
Normal file
17
packages/dashboard/app/components/Header.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
interface HeaderProps {
|
||||
onNewTask: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onNewTask }: HeaderProps) {
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<h1 className="logo">hai</h1>
|
||||
<span className="logo-sub">board</span>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={onNewTask}>
|
||||
+ New Task
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
54
packages/dashboard/app/components/TaskCard.tsx
Normal file
54
packages/dashboard/app/components/TaskCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Task, TaskDetail } from "@hai/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onOpenDetail, addToast }: TaskCardProps) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent) => {
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDragging(true);
|
||||
}, [task.id]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, onOpenDetail, addToast]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`card${dragging ? " dragging" : ""}`}
|
||||
data-id={task.id}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="card-id">{task.id}</span>
|
||||
<div className="card-title">{task.title}</div>
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="card-meta">
|
||||
<span className="card-dep-badge">
|
||||
⛓ {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
packages/dashboard/app/components/TaskDetailModal.tsx
Normal file
138
packages/dashboard/app/components/TaskDetailModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { Task, TaskDetail, Column, MergeResult } from "@hai/core";
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: TaskDetail;
|
||||
onClose: () => void;
|
||||
onMoveTask: (id: string, column: Column) => Promise<Task>;
|
||||
onDeleteTask: (id: string) => Promise<Task>;
|
||||
onMergeTask: (id: string) => Promise<MergeResult>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
onClose,
|
||||
onMoveTask,
|
||||
onDeleteTask,
|
||||
onMergeTask,
|
||||
addToast,
|
||||
}: TaskDetailModalProps) {
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
async (column: Column) => {
|
||||
try {
|
||||
await onMoveTask(task.id, column);
|
||||
onClose();
|
||||
addToast(`Moved to ${COLUMN_LABELS[column]}`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
},
|
||||
[task.id, onMoveTask, onClose, addToast],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!confirm(`Delete ${task.id}?`)) return;
|
||||
try {
|
||||
await onDeleteTask(task.id);
|
||||
onClose();
|
||||
addToast(`Deleted ${task.id}`, "info");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, onDeleteTask, onClose, addToast]);
|
||||
|
||||
const handleMerge = useCallback(async () => {
|
||||
if (!confirm(`Merge ${task.id} into the current branch?`)) return;
|
||||
try {
|
||||
const result = await onMergeTask(task.id);
|
||||
onClose();
|
||||
const msg = result.merged
|
||||
? `Merged ${task.id} (branch: ${result.branch})`
|
||||
: `Closed ${task.id} (${result.error || "no branch to merge"})`;
|
||||
addToast(msg, "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
}
|
||||
}, [task.id, onMergeTask, onClose, addToast]);
|
||||
|
||||
const transitions = VALID_TRANSITIONS[task.column] || [];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick}>
|
||||
<div className="modal modal-lg">
|
||||
<div className="modal-header">
|
||||
<div className="detail-title-row">
|
||||
<span className="detail-id">{task.id}</span>
|
||||
<span className={`detail-column-badge badge-${task.column}`}>
|
||||
{COLUMN_LABELS[task.column]}
|
||||
</span>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="detail-body">
|
||||
<h2 className="detail-title">{task.title}</h2>
|
||||
<div className="detail-meta">
|
||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
||||
{new Date(task.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<h4>PROMPT.md</h4>
|
||||
<pre className="detail-prompt">{task.prompt || "(no prompt)"}</pre>
|
||||
</div>
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="detail-deps">
|
||||
<h4>Dependencies</h4>
|
||||
<ul className="detail-dep-list">
|
||||
{task.dependencies.map((dep) => (
|
||||
<li key={dep}>{dep}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-danger btn-sm" onClick={handleDelete}>
|
||||
Delete
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
{task.column === "in-review" ? (
|
||||
<>
|
||||
<button className="btn btn-sm" onClick={() => handleMove("in-progress")}>
|
||||
Back to In Progress
|
||||
</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleMerge}>
|
||||
Merge & Close
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
transitions.map((col) => (
|
||||
<button key={col} className="btn btn-sm" onClick={() => handleMove(col)}>
|
||||
Move to {COLUMN_LABELS[col]}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
packages/dashboard/app/components/ToastContainer.tsx
Normal file
18
packages/dashboard/app/components/ToastContainer.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Toast } from "../hooks/useToast";
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[];
|
||||
onRemove: (id: number) => void;
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts }: ToastContainerProps) {
|
||||
return (
|
||||
<div className="toast-container" id="toasts">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast toast-${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
packages/dashboard/app/hooks/useTasks.ts
Normal file
72
packages/dashboard/app/hooks/useTasks.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task, Column, TaskCreateInput, MergeResult } from "@hai/core";
|
||||
import * as api from "../api";
|
||||
|
||||
export function useTasks() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const tasksRef = useRef(tasks);
|
||||
tasksRef.current = tasks;
|
||||
|
||||
// Fetch initial tasks
|
||||
useEffect(() => {
|
||||
api.fetchTasks().then(setTasks).catch(() => setTasks([]));
|
||||
}, []);
|
||||
|
||||
// SSE live updates
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/events");
|
||||
|
||||
es.addEventListener("task:created", (e) => {
|
||||
const task: Task = JSON.parse(e.data);
|
||||
setTasks((prev) => [...prev, task]);
|
||||
});
|
||||
|
||||
es.addEventListener("task:moved", (e) => {
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
|
||||
});
|
||||
|
||||
es.addEventListener("task:updated", (e) => {
|
||||
const task: Task = JSON.parse(e.data);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
|
||||
});
|
||||
|
||||
es.addEventListener("task:deleted", (e) => {
|
||||
const task: Task = JSON.parse(e.data);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||
});
|
||||
|
||||
es.addEventListener("task:merged", (e) => {
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
setTimeout(() => {
|
||||
if (es.readyState === EventSource.CLOSED) {
|
||||
// Will reconnect via new effect cycle
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
|
||||
return api.createTask(input);
|
||||
}, []);
|
||||
|
||||
const moveTask = useCallback(async (id: string, column: Column): Promise<Task> => {
|
||||
return api.moveTask(id, column);
|
||||
}, []);
|
||||
|
||||
const deleteTask = useCallback(async (id: string): Promise<Task> => {
|
||||
return api.deleteTask(id);
|
||||
}, []);
|
||||
|
||||
const mergeTask = useCallback(async (id: string): Promise<MergeResult> => {
|
||||
return api.mergeTask(id);
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask, mergeTask };
|
||||
}
|
||||
42
packages/dashboard/app/hooks/useToast.ts
Normal file
42
packages/dashboard/app/hooks/useToast.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState, useCallback, useRef, createContext, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
|
||||
export type ToastType = "success" | "error" | "info";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
removeToast: (id: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const nextId = useRef(0);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((message: string, type: ToastType = "info") => {
|
||||
const id = nextId.current++;
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
setTimeout(() => removeToast(id), 4000);
|
||||
}, [removeToast]);
|
||||
|
||||
return React.createElement(ToastContext.Provider, { value: { toasts, addToast, removeToast } }, children);
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
12
packages/dashboard/app/index.html
Normal file
12
packages/dashboard/app/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>hai — board</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
packages/dashboard/app/main.tsx
Normal file
10
packages/dashboard/app/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
414
packages/dashboard/app/styles.css
Normal file
414
packages/dashboard/app/styles.css
Normal file
@@ -0,0 +1,414 @@
|
||||
/* === Reset & Tokens === */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--card: #21262d;
|
||||
--card-hover: #282e36;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--text-dim: #484f58;
|
||||
|
||||
--triage: #d29922;
|
||||
--todo: #58a6ff;
|
||||
--in-progress: #bc8cff;
|
||||
--in-review: #3fb950;
|
||||
--done: #8b949e;
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* === Header === */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: baseline; gap: 8px; }
|
||||
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.logo-sub { font-size: 13px; color: var(--text-muted); font-weight: 400; }
|
||||
|
||||
/* === Buttons === */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn:hover { background: var(--card-hover); border-color: var(--text-muted); }
|
||||
|
||||
.btn-primary {
|
||||
background: #238636;
|
||||
border-color: #2ea043;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: #2ea043; }
|
||||
|
||||
.btn-danger {
|
||||
background: #da3633;
|
||||
border-color: #f85149;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover { background: #f85149; }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* === Board === */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
height: calc(100vh - 57px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.column.drag-over {
|
||||
border-color: var(--todo);
|
||||
box-shadow: inset 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 0;
|
||||
}
|
||||
|
||||
.column-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot-triage { background: var(--triage); }
|
||||
.dot-todo { background: var(--todo); }
|
||||
.dot-in-progress { background: var(--in-progress); }
|
||||
.dot-in-review { background: var(--in-review); }
|
||||
.dot-done { background: var(--done); }
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.column-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--card);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.column-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 14px 10px;
|
||||
}
|
||||
|
||||
.column-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.column-body::-webkit-scrollbar { width: 4px; }
|
||||
.column-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.column-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
/* === Cards === */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
cursor: grab;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.15s, opacity 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.card:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
.card:active { cursor: grabbing; }
|
||||
.card.dragging { opacity: 0.4; transform: scale(0.98); }
|
||||
|
||||
.card-id {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.card-dep-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--triage);
|
||||
}
|
||||
|
||||
/* === Modals === */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 100;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 480px;
|
||||
max-height: 80vh;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-lg { width: 640px; }
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h3 { font-size: 15px; font-weight: 600; }
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* === Forms === */
|
||||
.form-group { padding: 0 20px; margin-top: 16px; }
|
||||
.form-group:last-of-type { margin-bottom: 8px; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.optional { font-weight: 400; text-transform: none; letter-spacing: 0; }
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--todo);
|
||||
}
|
||||
.form-group textarea { resize: vertical; }
|
||||
|
||||
/* === Detail Modal === */
|
||||
.detail-title-row { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.detail-id {
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-column-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge-triage { background: rgba(210,153,34,0.15); color: var(--triage); }
|
||||
.badge-todo { background: rgba(88,166,255,0.15); color: var(--todo); }
|
||||
.badge-in-progress { background: rgba(188,140,255,0.15); color: var(--in-progress); }
|
||||
.badge-in-review { background: rgba(63,185,80,0.15); color: var(--in-review); }
|
||||
.badge-done { background: rgba(139,148,158,0.15); color: var(--done); }
|
||||
|
||||
.detail-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-section { margin-top: 16px; }
|
||||
.detail-section h4 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-prompt {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-muted);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-deps { margin-top: 16px; }
|
||||
.detail-deps h4 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.detail-dep-list {
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.detail-dep-list li {
|
||||
padding: 4px 0;
|
||||
color: var(--todo);
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* === Toasts === */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
animation: toast-in 0.25s ease-out;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.toast-success { background: #238636; }
|
||||
.toast-error { background: #da3633; }
|
||||
.toast-info { background: #1f6feb; }
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* === Empty state === */
|
||||
.empty-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin: 4px;
|
||||
}
|
||||
@@ -6,15 +6,23 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"build": "vite build && tsc",
|
||||
"build:client": "vite build",
|
||||
"dev:client": "vite",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hai/core": "workspace:*",
|
||||
"express": "^5.1.0"
|
||||
"express": "^5.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,6 @@ export function createApiRoutes(store: TaskStore): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
res.status(404).json({ error: `Task ${req.params.id} not found` });
|
||||
}
|
||||
});
|
||||
|
||||
// Create task
|
||||
router.post("/tasks", async (req, res) => {
|
||||
try {
|
||||
@@ -63,6 +53,29 @@ export function createApiRoutes(store: TaskStore): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Merge task (in-review → done, merges branch + cleans worktree)
|
||||
router.post("/tasks/:id/merge", async (req, res) => {
|
||||
try {
|
||||
const result = await store.mergeTask(req.params.id);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
const status = err.message.includes("Cannot merge") ? 400
|
||||
: err.message.includes("Merge conflict") ? 409
|
||||
: 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
const task = await store.getTask(req.params.id);
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
res.status(404).json({ error: `Task ${req.params.id} not found` });
|
||||
}
|
||||
});
|
||||
|
||||
// Update task
|
||||
router.patch("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
@@ -78,19 +91,6 @@ export function createApiRoutes(store: TaskStore): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// Merge task (in-review → done, merges branch + cleans worktree)
|
||||
router.post("/tasks/:id/merge", async (req, res) => {
|
||||
try {
|
||||
const result = await store.mergeTask(req.params.id);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
const status = err.message.includes("Cannot merge") ? 400
|
||||
: err.message.includes("Merge conflict") ? 409
|
||||
: 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete task
|
||||
router.delete("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from "express";
|
||||
import { join, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { TaskStore } from "@hai/core";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
@@ -7,11 +8,18 @@ import { createSSE } from "./sse.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function createServer(store: TaskStore) {
|
||||
export function createServer(store: TaskStore): ReturnType<typeof express> {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(join(__dirname, "..", "public")));
|
||||
// Serve built React app — check both possible locations
|
||||
const clientDir = existsSync(join(__dirname, "..", "dist", "client"))
|
||||
? join(__dirname, "..", "dist", "client")
|
||||
: existsSync(join(__dirname, "..", "client"))
|
||||
? join(__dirname, "..", "client")
|
||||
: join(__dirname, "..", "public"); // fallback to old public dir
|
||||
|
||||
app.use(express.static(clientDir));
|
||||
|
||||
// SSE endpoint
|
||||
app.get("/api/events", createSSE(store));
|
||||
@@ -21,7 +29,7 @@ export function createServer(store: TaskStore) {
|
||||
|
||||
// SPA fallback
|
||||
app.get("/{*splat}", (_req, res) => {
|
||||
res.sendFile(join(__dirname, "..", "public", "index.html"));
|
||||
res.sendFile(join(clientDir, "index.html"));
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
11
packages/dashboard/tsconfig.app.json
Normal file
11
packages/dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["app"]
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
22
packages/dashboard/vite.config.ts
Normal file
22
packages/dashboard/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
root: "app",
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@hai/core": resolve(__dirname, "../core/src/types.ts"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../dist/client",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user