feat: add Zustand stores with undo/redo and debounced persistence
- App store: theme, view routing, board list, settings - Board store: all card/column/label/checklist/attachment mutations - zundo temporal middleware for undo/redo (50 step limit) - Debounced saves (500ms) with immediate flush on close
This commit is contained in:
469
src/stores/board-store.ts
Normal file
469
src/stores/board-store.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
addCard: (columnId: string, title: string) => string;
|
||||
updateCard: (cardId: string, updates: Partial<Card>) => void;
|
||||
deleteCard: (cardId: string) => void;
|
||||
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;
|
||||
|
||||
addAttachment: (cardId: string, attachment: Omit<Attachment, "id">) => void;
|
||||
removeAttachment: (cardId: string, attachmentId: 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,
|
||||
set: (partial: Partial<BoardState>) => void
|
||||
): void {
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(async () => {
|
||||
set({ saving: true });
|
||||
try {
|
||||
await saveBoard(board);
|
||||
set({ saving: false, lastSaved: Date.now() });
|
||||
} catch {
|
||||
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, 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) => {
|
||||
mutate(get, set, (b) => ({
|
||||
...b,
|
||||
updatedAt: now(),
|
||||
columns: [
|
||||
...b.columns,
|
||||
{
|
||||
id: ulid(),
|
||||
title,
|
||||
cardIds: [],
|
||||
width: "standard" as ColumnWidth,
|
||||
},
|
||||
],
|
||||
}));
|
||||
},
|
||||
|
||||
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,
|
||||
columns: b.columns.map((c) =>
|
||||
c.id === columnId ? { ...c, width } : c
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// -- Card actions --
|
||||
|
||||
addCard: (columnId, title) => {
|
||||
const cardId = ulid();
|
||||
const card: Card = {
|
||||
id: cardId,
|
||||
title,
|
||||
description: "",
|
||||
labels: [],
|
||||
checklist: [],
|
||||
dueDate: null,
|
||||
attachments: [],
|
||||
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),
|
||||
})),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
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
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// -- 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
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// -- 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 }),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user