408 lines
12 KiB
TypeScript
408 lines
12 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,
|
|
updateTodo as updateTodoAction,
|
|
toggleTodo as toggleTodoAction,
|
|
deleteTodo as deleteTodoAction,
|
|
} from "@/lib/actions/todos";
|
|
import { updateGroupNote } from "@/lib/actions/notes";
|
|
|
|
interface BoardContextValue {
|
|
categories: CategoryDTO[];
|
|
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>;
|
|
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,
|
|
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;
|
|
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 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,
|
|
addCategory,
|
|
removeCategory,
|
|
reorderLanes,
|
|
addGroup,
|
|
editGroup,
|
|
removeGroup,
|
|
archiveGroup,
|
|
reorderGroups,
|
|
moveGroup,
|
|
saveNote,
|
|
addTodo,
|
|
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;
|
|
}
|