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:
69
src/stores/app-store.ts
Normal file
69
src/stores/app-store.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { AppSettings } from "@/types/settings";
|
||||||
|
import type { BoardMeta } from "@/types/board";
|
||||||
|
import { loadSettings, saveSettings, listBoards, ensureDataDirs } from "@/lib/storage";
|
||||||
|
|
||||||
|
export type View = { type: "board-list" } | { type: "board"; boardId: string };
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
settings: AppSettings;
|
||||||
|
boards: BoardMeta[];
|
||||||
|
view: View;
|
||||||
|
initialized: boolean;
|
||||||
|
|
||||||
|
init: () => Promise<void>;
|
||||||
|
setTheme: (theme: AppSettings["theme"]) => void;
|
||||||
|
setView: (view: View) => void;
|
||||||
|
refreshBoards: () => Promise<void>;
|
||||||
|
addRecentBoard: (boardId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: AppSettings["theme"]): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (theme === "system") {
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
root.classList.toggle("dark", prefersDark);
|
||||||
|
} else {
|
||||||
|
root.classList.toggle("dark", theme === "dark");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set, get) => ({
|
||||||
|
settings: { theme: "system", dataDirectory: null, recentBoardIds: [] },
|
||||||
|
boards: [],
|
||||||
|
view: { type: "board-list" },
|
||||||
|
initialized: false,
|
||||||
|
|
||||||
|
init: async () => {
|
||||||
|
await ensureDataDirs();
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const boards = await listBoards();
|
||||||
|
set({ settings, boards, initialized: true });
|
||||||
|
applyTheme(settings.theme);
|
||||||
|
},
|
||||||
|
|
||||||
|
setTheme: (theme) => {
|
||||||
|
const settings = { ...get().settings, theme };
|
||||||
|
set({ settings });
|
||||||
|
saveSettings(settings);
|
||||||
|
applyTheme(theme);
|
||||||
|
},
|
||||||
|
|
||||||
|
setView: (view) => set({ view }),
|
||||||
|
|
||||||
|
refreshBoards: async () => {
|
||||||
|
const boards = await listBoards();
|
||||||
|
set({ boards });
|
||||||
|
},
|
||||||
|
|
||||||
|
addRecentBoard: (boardId) => {
|
||||||
|
const settings = get().settings;
|
||||||
|
const recent = [
|
||||||
|
boardId,
|
||||||
|
...settings.recentBoardIds.filter((id) => id !== boardId),
|
||||||
|
].slice(0, 10);
|
||||||
|
const updated = { ...settings, recentBoardIds: recent };
|
||||||
|
set({ settings: updated });
|
||||||
|
saveSettings(updated);
|
||||||
|
},
|
||||||
|
}));
|
||||||
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