- PriorityPicker component with 5 colored chips in card detail modal - Card context menu: Move to column, Set priority, Duplicate, Delete - duplicateCard store action (clones card, inserts after original) - Column WIP limits with amber/red indicators when at/over limit - Column collapse/expand to 40px vertical strip - Checklist item drag reordering with grip handle - Comment store actions (addComment, deleteComment) for Phase 3
624 lines
17 KiB
TypeScript
624 lines
17 KiB
TypeScript
import { create } from "zustand";
|
|
import { temporal } from "zundo";
|
|
import { ulid } from "ulid";
|
|
import type {
|
|
Board,
|
|
Card,
|
|
Label,
|
|
ChecklistItem,
|
|
Attachment,
|
|
ColumnWidth,
|
|
} from "@/types/board";
|
|
import { saveBoard, loadBoard } from "@/lib/storage";
|
|
import { useAppStore } from "@/stores/app-store";
|
|
|
|
interface BoardState {
|
|
board: Board | null;
|
|
saving: boolean;
|
|
lastSaved: number | null;
|
|
}
|
|
|
|
interface BoardActions {
|
|
openBoard: (boardId: string) => Promise<void>;
|
|
closeBoard: () => void;
|
|
|
|
addColumn: (title: string) => void;
|
|
updateColumnTitle: (columnId: string, title: string) => void;
|
|
deleteColumn: (columnId: string) => void;
|
|
moveColumn: (fromIndex: number, toIndex: number) => void;
|
|
setColumnWidth: (columnId: string, width: ColumnWidth) => void;
|
|
setColumnColor: (columnId: string, color: string | null) => void;
|
|
setColumnWipLimit: (columnId: string, limit: number | null) => void;
|
|
toggleColumnCollapse: (columnId: string) => void;
|
|
|
|
addCard: (columnId: string, title: string) => string;
|
|
updateCard: (cardId: string, updates: Partial<Card>) => void;
|
|
deleteCard: (cardId: string) => void;
|
|
duplicateCard: (cardId: string) => string | null;
|
|
moveCard: (
|
|
cardId: string,
|
|
fromColumnId: string,
|
|
toColumnId: string,
|
|
toIndex: number
|
|
) => void;
|
|
|
|
addLabel: (name: string, color: string) => void;
|
|
updateLabel: (labelId: string, updates: Partial<Label>) => void;
|
|
deleteLabel: (labelId: string) => void;
|
|
toggleCardLabel: (cardId: string, labelId: string) => void;
|
|
|
|
addChecklistItem: (cardId: string, text: string) => void;
|
|
toggleChecklistItem: (cardId: string, itemId: string) => void;
|
|
updateChecklistItem: (cardId: string, itemId: string, text: string) => void;
|
|
deleteChecklistItem: (cardId: string, itemId: string) => void;
|
|
reorderChecklistItems: (cardId: string, fromIndex: number, toIndex: number) => void;
|
|
|
|
addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void;
|
|
removeAttachment: (cardId: string, attachmentId: string) => void;
|
|
|
|
addComment: (cardId: string, text: string) => void;
|
|
deleteComment: (cardId: string, commentId: string) => void;
|
|
|
|
updateBoardTitle: (title: string) => void;
|
|
updateBoardColor: (color: string) => void;
|
|
updateBoardSettings: (settings: Board["settings"]) => void;
|
|
}
|
|
|
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function now(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function debouncedSave(
|
|
board: Board,
|
|
get: () => BoardState & BoardActions,
|
|
set: (partial: Partial<BoardState>) => void
|
|
): void {
|
|
if (saveTimeout) clearTimeout(saveTimeout);
|
|
saveTimeout = setTimeout(async () => {
|
|
set({ saving: true });
|
|
try {
|
|
await saveBoard(board);
|
|
// Only update state if the same board is still loaded
|
|
if (get().board?.id === board.id) {
|
|
set({ saving: false, lastSaved: Date.now() });
|
|
}
|
|
} catch {
|
|
if (get().board?.id === board.id) {
|
|
set({ saving: false });
|
|
}
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
function mutate(
|
|
get: () => BoardState & BoardActions,
|
|
set: (partial: Partial<BoardState>) => void,
|
|
updater: (board: Board) => Board
|
|
): void {
|
|
const { board } = get();
|
|
if (!board) return;
|
|
const updated = updater(board);
|
|
set({ board: updated });
|
|
debouncedSave(updated, get, set);
|
|
}
|
|
|
|
export const useBoardStore = create<BoardState & BoardActions>()(
|
|
temporal(
|
|
(set, get) => ({
|
|
board: null,
|
|
saving: false,
|
|
lastSaved: null,
|
|
|
|
openBoard: async (boardId: string) => {
|
|
const board = await loadBoard(boardId);
|
|
set({ board, saving: false, lastSaved: null });
|
|
},
|
|
|
|
closeBoard: () => {
|
|
if (saveTimeout) {
|
|
clearTimeout(saveTimeout);
|
|
saveTimeout = null;
|
|
const { board } = get();
|
|
if (board) saveBoard(board);
|
|
}
|
|
set({ board: null, saving: false, lastSaved: null });
|
|
},
|
|
|
|
// -- Column actions --
|
|
|
|
addColumn: (title: string) => {
|
|
const defaultWidth = useAppStore.getState().settings.defaultColumnWidth;
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
columns: [
|
|
...b.columns,
|
|
{
|
|
id: ulid(),
|
|
title,
|
|
cardIds: [],
|
|
width: defaultWidth,
|
|
color: null,
|
|
collapsed: false,
|
|
wipLimit: null,
|
|
},
|
|
],
|
|
}));
|
|
},
|
|
|
|
updateColumnTitle: (columnId, title) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
columns: b.columns.map((c) =>
|
|
c.id === columnId ? { ...c, title } : c
|
|
),
|
|
}));
|
|
},
|
|
|
|
deleteColumn: (columnId) => {
|
|
mutate(get, set, (b) => {
|
|
const col = b.columns.find((c) => c.id === columnId);
|
|
if (!col) return b;
|
|
const newCards = { ...b.cards };
|
|
col.cardIds.forEach((id) => delete newCards[id]);
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
columns: b.columns.filter((c) => c.id !== columnId),
|
|
cards: newCards,
|
|
};
|
|
});
|
|
},
|
|
|
|
moveColumn: (fromIndex, toIndex) => {
|
|
mutate(get, set, (b) => {
|
|
const cols = [...b.columns];
|
|
const [moved] = cols.splice(fromIndex, 1);
|
|
cols.splice(toIndex, 0, moved);
|
|
return { ...b, updatedAt: now(), columns: cols };
|
|
});
|
|
},
|
|
|
|
setColumnWidth: (columnId, width) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
columns: b.columns.map((c) =>
|
|
c.id === columnId ? { ...c, width } : c
|
|
),
|
|
}));
|
|
},
|
|
|
|
setColumnColor: (columnId, color) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
columns: b.columns.map((c) =>
|
|
c.id === columnId ? { ...c, color } : c
|
|
),
|
|
}));
|
|
},
|
|
|
|
setColumnWipLimit: (columnId, limit) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
columns: b.columns.map((c) =>
|
|
c.id === columnId ? { ...c, wipLimit: limit } : c
|
|
),
|
|
}));
|
|
},
|
|
|
|
toggleColumnCollapse: (columnId) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
columns: b.columns.map((c) =>
|
|
c.id === columnId ? { ...c, collapsed: !c.collapsed } : c
|
|
),
|
|
}));
|
|
},
|
|
|
|
// -- Card actions --
|
|
|
|
addCard: (columnId, title) => {
|
|
const cardId = ulid();
|
|
const card: Card = {
|
|
id: cardId,
|
|
title,
|
|
description: "",
|
|
labels: [],
|
|
checklist: [],
|
|
dueDate: null,
|
|
attachments: [],
|
|
coverColor: null,
|
|
priority: "none",
|
|
comments: [],
|
|
createdAt: now(),
|
|
updatedAt: now(),
|
|
};
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: { ...b.cards, [cardId]: card },
|
|
columns: b.columns.map((c) =>
|
|
c.id === columnId
|
|
? { ...c, cardIds: [...c.cardIds, cardId] }
|
|
: c
|
|
),
|
|
}));
|
|
return cardId;
|
|
},
|
|
|
|
updateCard: (cardId, updates) => {
|
|
mutate(get, set, (b) => {
|
|
if (!b.cards[cardId]) return b;
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...b.cards[cardId],
|
|
...updates,
|
|
updatedAt: now(),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
deleteCard: (cardId) => {
|
|
mutate(get, set, (b) => {
|
|
const newCards = { ...b.cards };
|
|
delete newCards[cardId];
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: newCards,
|
|
columns: b.columns.map((c) => ({
|
|
...c,
|
|
cardIds: c.cardIds.filter((id) => id !== cardId),
|
|
})),
|
|
};
|
|
});
|
|
},
|
|
|
|
duplicateCard: (cardId) => {
|
|
const { board } = get();
|
|
if (!board) return null;
|
|
const original = board.cards[cardId];
|
|
if (!original) return null;
|
|
const column = board.columns.find((c) => c.cardIds.includes(cardId));
|
|
if (!column) return null;
|
|
|
|
const newId = ulid();
|
|
const ts = now();
|
|
const clone: Card = {
|
|
...original,
|
|
id: newId,
|
|
title: `${original.title} (copy)`,
|
|
comments: [],
|
|
createdAt: ts,
|
|
updatedAt: ts,
|
|
};
|
|
|
|
const insertIndex = column.cardIds.indexOf(cardId) + 1;
|
|
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: ts,
|
|
cards: { ...b.cards, [newId]: clone },
|
|
columns: b.columns.map((c) =>
|
|
c.id === column.id
|
|
? {
|
|
...c,
|
|
cardIds: [
|
|
...c.cardIds.slice(0, insertIndex),
|
|
newId,
|
|
...c.cardIds.slice(insertIndex),
|
|
],
|
|
}
|
|
: c
|
|
),
|
|
}));
|
|
|
|
return newId;
|
|
},
|
|
|
|
moveCard: (cardId, fromColumnId, toColumnId, toIndex) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
columns: b.columns.map((c) => {
|
|
if (c.id === fromColumnId && c.id === toColumnId) {
|
|
const ids = c.cardIds.filter((id) => id !== cardId);
|
|
ids.splice(toIndex, 0, cardId);
|
|
return { ...c, cardIds: ids };
|
|
}
|
|
if (c.id === fromColumnId) {
|
|
return {
|
|
...c,
|
|
cardIds: c.cardIds.filter((id) => id !== cardId),
|
|
};
|
|
}
|
|
if (c.id === toColumnId) {
|
|
const ids = [...c.cardIds];
|
|
ids.splice(toIndex, 0, cardId);
|
|
return { ...c, cardIds: ids };
|
|
}
|
|
return c;
|
|
}),
|
|
}));
|
|
},
|
|
|
|
// -- Label actions --
|
|
|
|
addLabel: (name, color) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
labels: [...b.labels, { id: ulid(), name, color }],
|
|
}));
|
|
},
|
|
|
|
updateLabel: (labelId, updates) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
labels: b.labels.map((l) =>
|
|
l.id === labelId ? { ...l, ...updates } : l
|
|
),
|
|
}));
|
|
},
|
|
|
|
deleteLabel: (labelId) => {
|
|
mutate(get, set, (b) => ({
|
|
...b,
|
|
updatedAt: now(),
|
|
labels: b.labels.filter((l) => l.id !== labelId),
|
|
cards: Object.fromEntries(
|
|
Object.entries(b.cards).map(([id, card]) => [
|
|
id,
|
|
{
|
|
...card,
|
|
labels: card.labels.filter((l) => l !== labelId),
|
|
},
|
|
])
|
|
),
|
|
}));
|
|
},
|
|
|
|
toggleCardLabel: (cardId, labelId) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
const labels = card.labels.includes(labelId)
|
|
? card.labels.filter((l) => l !== labelId)
|
|
: [...card.labels, labelId];
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: { ...card, labels, updatedAt: now() },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// -- Checklist actions --
|
|
|
|
addChecklistItem: (cardId, text) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
const item: ChecklistItem = { id: ulid(), text, checked: false };
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...card,
|
|
checklist: [...card.checklist, item],
|
|
updatedAt: now(),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
toggleChecklistItem: (cardId, itemId) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...card,
|
|
updatedAt: now(),
|
|
checklist: card.checklist.map((item) =>
|
|
item.id === itemId
|
|
? { ...item, checked: !item.checked }
|
|
: item
|
|
),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
updateChecklistItem: (cardId, itemId, text) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...card,
|
|
updatedAt: now(),
|
|
checklist: card.checklist.map((item) =>
|
|
item.id === itemId ? { ...item, text } : item
|
|
),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
deleteChecklistItem: (cardId, itemId) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...card,
|
|
updatedAt: now(),
|
|
checklist: card.checklist.filter(
|
|
(item) => item.id !== itemId
|
|
),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
reorderChecklistItems: (cardId, fromIndex, toIndex) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
const items = [...card.checklist];
|
|
const [moved] = items.splice(fromIndex, 1);
|
|
items.splice(toIndex, 0, moved);
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: { ...card, checklist: items, updatedAt: now() },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// -- Attachment actions --
|
|
|
|
addAttachment: (cardId, attachment) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...card,
|
|
updatedAt: now(),
|
|
attachments: [
|
|
...card.attachments,
|
|
{ ...attachment, id: ulid() },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
removeAttachment: (cardId, attachmentId) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...card,
|
|
updatedAt: now(),
|
|
attachments: card.attachments.filter(
|
|
(a) => a.id !== attachmentId
|
|
),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// -- Comment actions --
|
|
|
|
addComment: (cardId, text) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
const comment = { id: ulid(), text, createdAt: now() };
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...card,
|
|
comments: [comment, ...card.comments],
|
|
updatedAt: now(),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
deleteComment: (cardId, commentId) => {
|
|
mutate(get, set, (b) => {
|
|
const card = b.cards[cardId];
|
|
if (!card) return b;
|
|
return {
|
|
...b,
|
|
updatedAt: now(),
|
|
cards: {
|
|
...b.cards,
|
|
[cardId]: {
|
|
...card,
|
|
comments: card.comments.filter((c) => c.id !== commentId),
|
|
updatedAt: now(),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// -- Board metadata --
|
|
|
|
updateBoardTitle: (title) => {
|
|
mutate(get, set, (b) => ({ ...b, title, updatedAt: now() }));
|
|
},
|
|
|
|
updateBoardColor: (color) => {
|
|
mutate(get, set, (b) => ({ ...b, color, updatedAt: now() }));
|
|
},
|
|
|
|
updateBoardSettings: (settings) => {
|
|
mutate(get, set, (b) => ({ ...b, settings, updatedAt: now() }));
|
|
},
|
|
}),
|
|
{
|
|
limit: 50,
|
|
partialize: (state) => ({ board: state.board }),
|
|
}
|
|
)
|
|
);
|