Organize/components/board/board-context.tsx

435 lines
13 KiB
TypeScript

"use client";
import { createContext, useContext, useState, useCallback } from "react";
import { toast } from "sonner";
import type { CategoryDTO, GroupDTO, TodoDTO } from "@/types/board";
import {
createCategory,
deleteCategory as deleteCategoryAction,
reorderCategories,
} from "@/lib/actions/categories";
import {
createGroup,
updateGroup as updateGroupAction,
deleteGroup as deleteGroupAction,
archiveGroup as archiveGroupAction,
reorderGroupsInCategory,
moveGroupToCategory,
} from "@/lib/actions/groups";
import {
createTodo,
createTodos,
updateTodo as updateTodoAction,
toggleTodo as toggleTodoAction,
deleteTodo as deleteTodoAction,
} from "@/lib/actions/todos";
import { updateGroupNote } from "@/lib/actions/notes";
interface BoardContextValue {
categories: CategoryDTO[];
// Whether the admin-configured AI provider has an API URL + model saved
// -- gates AI-powered actions like the "Add using AI" to-do button.
aiConfigured: boolean;
addCategory: (name: string) => Promise<void>;
removeCategory: (categoryId: string) => Promise<void>;
reorderLanes: (orderedIds: string[]) => Promise<void>;
addGroup: (categoryId: string, title: string, color: string) => Promise<GroupDTO | undefined>;
editGroup: (
groupId: string,
categoryId: string,
data: { title?: string; color?: string }
) => Promise<void>;
removeGroup: (groupId: string, categoryId: string) => Promise<void>;
archiveGroup: (groupId: string, categoryId: string) => Promise<void>;
reorderGroups: (categoryId: string, orderedIds: string[]) => Promise<void>;
moveGroup: (
groupId: string,
fromCategoryId: string,
toCategoryId: string,
orderedTarget: string[],
orderedSource: string[]
) => Promise<void>;
saveNote: (groupId: string, categoryId: string, noteContent: string) => Promise<boolean>;
addTodo: (
groupId: string,
categoryId: string,
title: string,
details?: string
) => Promise<boolean>;
addTodos: (
groupId: string,
categoryId: string,
todos: { title: string; details?: string }[]
) => Promise<boolean>;
editTodo: (
todoId: string,
groupId: string,
categoryId: string,
data: { title?: string; details?: string | null }
) => Promise<boolean>;
toggleTodoDone: (
todoId: string,
groupId: string,
categoryId: string,
completed: boolean
) => Promise<void>;
removeTodo: (todoId: string, groupId: string, categoryId: string) => Promise<void>;
}
const BoardContext = createContext<BoardContextValue | null>(null);
export function BoardProvider({
initialCategories,
projectId,
aiConfigured,
children,
}: {
initialCategories: CategoryDTO[];
// Undefined means Home. The Project-scoped board reuses every bit of
// this provider -- the only calls that need to know which board they're
// on are the two that operate over "all of this board's categories"
// rather than a specific existing category/group/todo id.
projectId?: string;
aiConfigured: boolean;
children: React.ReactNode;
}) {
const [categories, setCategories] = useState(initialCategories);
const updateCategory = useCallback(
(categoryId: string, updater: (c: CategoryDTO) => CategoryDTO) => {
setCategories((prev) => prev.map((c) => (c.id === categoryId ? updater(c) : c)));
},
[]
);
const updateGroupInState = useCallback(
(categoryId: string, groupId: string, updater: (g: GroupDTO) => GroupDTO) => {
updateCategory(categoryId, (c) => ({
...c,
groups: c.groups.map((g) => (g.id === groupId ? updater(g) : g)),
}));
},
[updateCategory]
);
const addCategory = useCallback(
async (name: string) => {
try {
const category = await createCategory(name, projectId);
setCategories((prev) => [...prev, category]);
} catch {
toast.error("Couldn't create category. Try again.");
}
},
[projectId]
);
const removeCategory = useCallback(async (categoryId: string) => {
let prevState: CategoryDTO[] = [];
setCategories((prev) => {
prevState = prev;
return prev.filter((c) => c.id !== categoryId);
});
try {
const result = await deleteCategoryAction(categoryId);
if (result?.error) {
setCategories(prevState);
toast.error(result.error);
}
} catch {
setCategories(prevState);
toast.error("Couldn't delete category. Try again.");
}
}, []);
const reorderLanes = useCallback(
async (orderedIds: string[]) => {
let prevState: CategoryDTO[] = [];
setCategories((prev) => {
prevState = prev;
const byId = new Map(prev.map((c) => [c.id, c]));
return orderedIds.map((id, order) => ({ ...byId.get(id)!, order }));
});
try {
await reorderCategories(orderedIds, projectId);
} catch {
setCategories(prevState);
toast.error("Couldn't reorder categories. Try again.");
}
},
[projectId]
);
const addGroup = useCallback(async (categoryId: string, title: string, color: string) => {
try {
const group = await createGroup(categoryId, title, color);
updateCategory(categoryId, (c) => ({ ...c, groups: [...c.groups, group] }));
return group;
} catch {
toast.error("Couldn't create group. Try again.");
return undefined;
}
}, [updateCategory]);
const editGroup = useCallback(
async (groupId: string, categoryId: string, data: { title?: string; color?: string }) => {
try {
await updateGroupAction(groupId, data);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, ...data }));
} catch {
toast.error("Couldn't update group. Try again.");
}
},
[updateGroupInState]
);
const removeGroup = useCallback(async (groupId: string, categoryId: string) => {
let prevState: CategoryDTO[] = [];
setCategories((prev) => {
prevState = prev;
return prev.map((c) =>
c.id === categoryId ? { ...c, groups: c.groups.filter((g) => g.id !== groupId) } : c
);
});
try {
await deleteGroupAction(groupId);
} catch {
setCategories(prevState);
toast.error("Couldn't delete group. Try again.");
}
}, []);
const archiveGroup = useCallback(async (groupId: string, categoryId: string) => {
let prevState: CategoryDTO[] = [];
setCategories((prev) => {
prevState = prev;
return prev.map((c) =>
c.id === categoryId ? { ...c, groups: c.groups.filter((g) => g.id !== groupId) } : c
);
});
try {
await archiveGroupAction(groupId);
} catch {
setCategories(prevState);
toast.error("Couldn't archive group. Try again.");
}
}, []);
const reorderGroups = useCallback(async (categoryId: string, orderedIds: string[]) => {
let prevState: CategoryDTO[] = [];
setCategories((prev) => {
prevState = prev;
return prev.map((c) => {
if (c.id !== categoryId) return c;
const byId = new Map(c.groups.map((g) => [g.id, g]));
return { ...c, groups: orderedIds.map((id, order) => ({ ...byId.get(id)!, order })) };
});
});
try {
await reorderGroupsInCategory(categoryId, orderedIds);
} catch {
setCategories(prevState);
toast.error("Couldn't reorder groups. Try again.");
}
}, []);
const moveGroup = useCallback(
async (
groupId: string,
fromCategoryId: string,
toCategoryId: string,
orderedTarget: string[],
orderedSource: string[]
) => {
let prevState: CategoryDTO[] = [];
setCategories((prev) => {
prevState = prev;
const sourceCat = prev.find((c) => c.id === fromCategoryId);
const movedGroup = sourceCat?.groups.find((g) => g.id === groupId);
if (!movedGroup) return prev;
return prev.map((c) => {
if (c.id === fromCategoryId) {
const byId = new Map(c.groups.map((g) => [g.id, g]));
return {
...c,
groups: orderedSource.map((id, order) => ({ ...byId.get(id)!, order })),
};
}
if (c.id === toCategoryId) {
const byId = new Map(c.groups.map((g) => [g.id, g]));
byId.set(groupId, movedGroup);
return {
...c,
groups: orderedTarget.map((id, order) => ({
...byId.get(id)!,
categoryId: toCategoryId,
order,
})),
};
}
return c;
});
});
try {
await moveGroupToCategory(groupId, toCategoryId, orderedTarget, orderedSource);
} catch {
setCategories(prevState);
toast.error("Couldn't move group. Try again.");
}
},
[]
);
const saveNote = useCallback(
async (groupId: string, categoryId: string, noteContent: string) => {
try {
await updateGroupNote(groupId, noteContent);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, noteContent }));
return true;
} catch {
toast.error("Couldn't save note. Try again.");
return false;
}
},
[updateGroupInState]
);
const addTodo = useCallback(
async (groupId: string, categoryId: string, title: string, details?: string) => {
try {
const todo = await createTodo(groupId, title, details);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, todo] }));
return true;
} catch {
toast.error("Couldn't add to-do. Try again.");
return false;
}
},
[updateGroupInState]
);
const addTodos = useCallback(
async (groupId: string, categoryId: string, todos: { title: string; details?: string }[]) => {
try {
const created = await createTodos(groupId, todos);
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: [...g.todos, ...created] }));
return true;
} catch {
toast.error("Couldn't add to-dos. Try again.");
return false;
}
},
[updateGroupInState]
);
const editTodo = useCallback(
async (
todoId: string,
groupId: string,
categoryId: string,
data: { title?: string; details?: string | null }
) => {
try {
await updateTodoAction(todoId, data);
// Approximates the server's own `updatedAt` (set by the same write,
// a moment later) closely enough for display purposes, without
// waiting on a round trip just to read it back.
const updatedAt = new Date().toISOString();
updateGroupInState(categoryId, groupId, (g) => ({
...g,
todos: g.todos.map((t) => (t.id === todoId ? { ...t, ...data, updatedAt } : t)),
}));
return true;
} catch {
toast.error("Couldn't update to-do. Try again.");
return false;
}
},
[updateGroupInState]
);
const toggleTodoDone = useCallback(
async (todoId: string, groupId: string, categoryId: string, completed: boolean) => {
const now = new Date().toISOString();
let prevTodos: TodoDTO[] = [];
updateCategory(categoryId, (c) => ({
...c,
groups: c.groups.map((g) => {
if (g.id !== groupId) return g;
prevTodos = g.todos;
return {
...g,
todos: g.todos.map((t) =>
t.id === todoId
? { ...t, completed, completedAt: completed ? now : null, updatedAt: now }
: t
),
};
}),
}));
try {
await toggleTodoAction(todoId, completed);
} catch {
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
toast.error("Couldn't update to-do. Try again.");
}
},
[updateCategory, updateGroupInState]
);
const removeTodo = useCallback(
async (todoId: string, groupId: string, categoryId: string) => {
let prevTodos: TodoDTO[] = [];
updateCategory(categoryId, (c) => ({
...c,
groups: c.groups.map((g) => {
if (g.id !== groupId) return g;
prevTodos = g.todos;
return { ...g, todos: g.todos.filter((t) => t.id !== todoId) };
}),
}));
try {
await deleteTodoAction(todoId);
} catch {
updateGroupInState(categoryId, groupId, (g) => ({ ...g, todos: prevTodos }));
toast.error("Couldn't delete to-do. Try again.");
}
},
[updateCategory, updateGroupInState]
);
return (
<BoardContext.Provider
value={{
categories,
aiConfigured,
addCategory,
removeCategory,
reorderLanes,
addGroup,
editGroup,
removeGroup,
archiveGroup,
reorderGroups,
moveGroup,
saveNote,
addTodo,
addTodos,
editTodo,
toggleTodoDone,
removeTodo,
}}
>
{children}
</BoardContext.Provider>
);
}
export function useBoard() {
const ctx = useContext(BoardContext);
if (!ctx) throw new Error("useBoard must be used within a BoardProvider");
return ctx;
}