feat(HAI-002): implement shared hooks and API layer
This commit is contained in:
37
packages/dashboard/app/api.ts
Normal file
37
packages/dashboard/app/api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Task, TaskDetail, TaskCreateInput, Column } 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" });
|
||||
}
|
||||
63
packages/dashboard/app/hooks/useTasks.ts
Normal file
63
packages/dashboard/app/hooks/useTasks.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task, Column, TaskCreateInput } 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("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);
|
||||
}, []);
|
||||
|
||||
return { tasks, createTask, moveTask, deleteTask };
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user