378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import type { DeleteReference, ConfirmDeleteDetails } from '../components/ConfirmDeleteModal';
|
|
import type {
|
|
ProjectData,
|
|
PostData,
|
|
MediaData,
|
|
TaskProgress,
|
|
} from '../../main/shared/electronApi';
|
|
|
|
// Storage key for persisted state
|
|
const STORAGE_KEY = 'bds-app-state';
|
|
|
|
// Tab types
|
|
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'metadata-diff';
|
|
|
|
export interface Tab {
|
|
type: TabType;
|
|
id: string;
|
|
isTransient: boolean;
|
|
}
|
|
|
|
export interface TabState {
|
|
tabs: Tab[];
|
|
activeTabId: string | null;
|
|
}
|
|
|
|
// Types
|
|
export type { ProjectData, PostData, MediaData, TaskProgress };
|
|
|
|
export interface ErrorDetails {
|
|
message: string;
|
|
title?: string;
|
|
stack?: string;
|
|
}
|
|
|
|
// Re-export types from ConfirmDeleteModal for convenience
|
|
export type { DeleteReference, ConfirmDeleteDetails };
|
|
|
|
export type EditorMode = 'wysiwyg' | 'markdown' | 'preview';
|
|
|
|
// App State Store
|
|
interface AppState {
|
|
// Projects
|
|
projects: ProjectData[];
|
|
activeProject: ProjectData | null;
|
|
|
|
// Tabs
|
|
tabs: Tab[];
|
|
activeTabId: string | null;
|
|
|
|
// UI State
|
|
activeView: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
|
sidebarVisible: boolean;
|
|
panelVisible: boolean;
|
|
selectedPostId: string | null;
|
|
selectedMediaId: string | null;
|
|
preferredEditorMode: EditorMode;
|
|
|
|
// Data
|
|
posts: PostData[];
|
|
media: MediaData[];
|
|
tasks: TaskProgress[];
|
|
|
|
// Pagination
|
|
hasMorePosts: boolean;
|
|
totalPosts: number;
|
|
|
|
// Track which posts have unsaved changes (by post ID)
|
|
dirtyPosts: Set<string>;
|
|
|
|
// Error modal
|
|
errorModal: ErrorDetails | null;
|
|
|
|
// Confirm delete modal
|
|
confirmDeleteModal: ConfirmDeleteDetails | null;
|
|
|
|
// Loading states
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
// Project Actions
|
|
setProjects: (projects: ProjectData[]) => void;
|
|
setActiveProject: (project: ProjectData | null) => void;
|
|
addProject: (project: ProjectData) => void;
|
|
updateProject: (id: string, project: Partial<ProjectData>) => void;
|
|
removeProject: (id: string) => void;
|
|
|
|
// Tab Actions
|
|
openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void;
|
|
closeTab: (id: string) => void;
|
|
setActiveTab: (id: string) => void;
|
|
pinTab: (id: string) => void;
|
|
clearTabs: () => void;
|
|
getTabState: () => TabState;
|
|
restoreTabState: (state: TabState) => void;
|
|
|
|
// Actions
|
|
setActiveView: (view: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
|
|
toggleSidebar: () => void;
|
|
togglePanel: () => void;
|
|
setSelectedPost: (id: string | null) => void;
|
|
setSelectedMedia: (id: string | null) => void;
|
|
setPreferredEditorMode: (mode: EditorMode) => void;
|
|
|
|
setPosts: (posts: PostData[], hasMore?: boolean, total?: number) => void;
|
|
appendPosts: (posts: PostData[], hasMore: boolean) => void;
|
|
addPost: (post: PostData) => void;
|
|
updatePost: (id: string, post: Partial<PostData>) => void;
|
|
removePost: (id: string) => void;
|
|
|
|
// Dirty tracking
|
|
markDirty: (id: string) => void;
|
|
markClean: (id: string) => void;
|
|
isDirty: (id: string) => boolean;
|
|
|
|
// Error modal actions
|
|
showErrorModal: (error: ErrorDetails) => void;
|
|
hideErrorModal: () => void;
|
|
|
|
// Confirm delete modal actions
|
|
showConfirmDeleteModal: (details: ConfirmDeleteDetails) => void;
|
|
hideConfirmDeleteModal: () => void;
|
|
|
|
setMedia: (media: MediaData[]) => void;
|
|
addMedia: (media: MediaData) => void;
|
|
updateMedia: (id: string, media: Partial<MediaData>) => void;
|
|
removeMedia: (id: string) => void;
|
|
|
|
setTasks: (tasks: TaskProgress[]) => void;
|
|
updateTask: (taskId: string, task: Partial<TaskProgress>) => void;
|
|
|
|
// Loading Actions
|
|
setLoading: (loading: boolean) => void;
|
|
setError: (error: string | null) => void;
|
|
}
|
|
|
|
export const useAppStore = create<AppState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
// Initial Project State
|
|
projects: [],
|
|
activeProject: null,
|
|
|
|
// Initial Tabs State
|
|
tabs: [],
|
|
activeTabId: null,
|
|
|
|
// Initial UI State
|
|
activeView: 'posts',
|
|
sidebarVisible: true,
|
|
panelVisible: false,
|
|
selectedPostId: null,
|
|
selectedMediaId: null,
|
|
preferredEditorMode: 'wysiwyg',
|
|
|
|
// Initial Data
|
|
posts: [],
|
|
media: [],
|
|
tasks: [],
|
|
|
|
// Pagination
|
|
hasMorePosts: false,
|
|
totalPosts: 0,
|
|
|
|
// Dirty posts tracking
|
|
dirtyPosts: new Set<string>(),
|
|
|
|
// Error modal
|
|
errorModal: null,
|
|
|
|
// Confirm delete modal
|
|
confirmDeleteModal: null,
|
|
|
|
// Initial Loading State
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
// Project Actions
|
|
setProjects: (projects) => set({ projects }),
|
|
setActiveProject: (activeProject) => set({ activeProject }),
|
|
addProject: (project) => set((state) => ({ projects: [...state.projects, project] })),
|
|
updateProject: (id, updatedProject) => set((state) => ({
|
|
projects: state.projects.map((p) => (p.id === id ? { ...p, ...updatedProject } : p)),
|
|
})),
|
|
removeProject: (id) => set((state) => ({
|
|
projects: state.projects.filter((p) => p.id !== id),
|
|
})),
|
|
|
|
// Tab Actions
|
|
openTab: ({ type, id, isTransient }) => set((state) => {
|
|
const existingTabIndex = state.tabs.findIndex((t) => t.id === id && t.type === type);
|
|
|
|
if (existingTabIndex >= 0) {
|
|
// Tab already exists - if trying to pin (isTransient=false), update it
|
|
if (!isTransient) {
|
|
const updatedTabs = [...state.tabs];
|
|
updatedTabs[existingTabIndex] = { ...updatedTabs[existingTabIndex], isTransient: false };
|
|
return { tabs: updatedTabs, activeTabId: id };
|
|
}
|
|
// Just switch to the existing tab
|
|
return { activeTabId: id };
|
|
}
|
|
|
|
// If opening as transient, replace existing transient tab of the same type
|
|
if (isTransient) {
|
|
const transientIndex = state.tabs.findIndex((t) => t.isTransient && t.type === type);
|
|
if (transientIndex >= 0) {
|
|
const updatedTabs = [...state.tabs];
|
|
updatedTabs[transientIndex] = { type, id, isTransient: true };
|
|
return { tabs: updatedTabs, activeTabId: id };
|
|
}
|
|
}
|
|
|
|
// Add new tab
|
|
const newTab: Tab = { type, id, isTransient };
|
|
return { tabs: [...state.tabs, newTab], activeTabId: id };
|
|
}),
|
|
|
|
closeTab: (id) => set((state) => {
|
|
const tabIndex = state.tabs.findIndex((t) => t.id === id);
|
|
if (tabIndex === -1) return state;
|
|
|
|
const newTabs = state.tabs.filter((t) => t.id !== id);
|
|
let newActiveTabId = state.activeTabId;
|
|
|
|
// If closing the active tab, activate an adjacent tab
|
|
if (state.activeTabId === id) {
|
|
if (newTabs.length === 0) {
|
|
newActiveTabId = null;
|
|
} else if (tabIndex < newTabs.length) {
|
|
// Activate the tab that moved into this position (next tab)
|
|
newActiveTabId = newTabs[tabIndex].id;
|
|
} else {
|
|
// Activate the previous tab
|
|
newActiveTabId = newTabs[newTabs.length - 1].id;
|
|
}
|
|
}
|
|
|
|
return { tabs: newTabs, activeTabId: newActiveTabId };
|
|
}),
|
|
|
|
setActiveTab: (id) => set((state) => {
|
|
// Only set if the tab exists
|
|
const tabExists = state.tabs.some((t) => t.id === id);
|
|
if (!tabExists) return state;
|
|
return { activeTabId: id };
|
|
}),
|
|
|
|
pinTab: (id) => set((state) => ({
|
|
tabs: state.tabs.map((t) => (t.id === id ? { ...t, isTransient: false } : t)),
|
|
})),
|
|
|
|
clearTabs: () => set({ tabs: [], activeTabId: null }),
|
|
|
|
getTabState: () => {
|
|
const state = get();
|
|
return { tabs: state.tabs, activeTabId: state.activeTabId };
|
|
},
|
|
|
|
restoreTabState: (tabState) => set({
|
|
tabs: tabState.tabs,
|
|
activeTabId: tabState.activeTabId
|
|
}),
|
|
|
|
// UI Actions
|
|
setActiveView: (view) => set({ activeView: view }),
|
|
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
|
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
|
|
setSelectedPost: (id) => set({ selectedPostId: id }),
|
|
setSelectedMedia: (id) => set({ selectedMediaId: id }),
|
|
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
|
|
|
// Post Actions
|
|
setPosts: (posts, hasMore = false, total = 0) => set({ posts, hasMorePosts: hasMore, totalPosts: total }),
|
|
appendPosts: (newPosts, hasMore) => set((state) => {
|
|
const existingIds = new Set(state.posts.map(p => p.id));
|
|
const uniqueNewPosts = newPosts.filter(p => !existingIds.has(p.id));
|
|
return {
|
|
posts: [...state.posts, ...uniqueNewPosts],
|
|
hasMorePosts: hasMore,
|
|
};
|
|
}),
|
|
addPost: (post) => set((state) => {
|
|
if (state.posts.some(p => p.id === post.id)) return state;
|
|
return { posts: [post, ...state.posts], totalPosts: state.totalPosts + 1 };
|
|
}),
|
|
updatePost: (id, updatedPost) => set((state) => ({
|
|
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
|
|
})),
|
|
removePost: (id) => set((state) => {
|
|
const newDirtyPosts = new Set(state.dirtyPosts);
|
|
newDirtyPosts.delete(id);
|
|
return {
|
|
posts: state.posts.filter((p) => p.id !== id),
|
|
dirtyPosts: newDirtyPosts,
|
|
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
|
|
};
|
|
}),
|
|
|
|
// Dirty tracking
|
|
markDirty: (id) => set((state) => ({
|
|
dirtyPosts: new Set([...state.dirtyPosts, id]),
|
|
})),
|
|
|
|
markClean: (id) => set((state) => {
|
|
const newDirtyPosts = new Set(state.dirtyPosts);
|
|
newDirtyPosts.delete(id);
|
|
return { dirtyPosts: newDirtyPosts };
|
|
}),
|
|
|
|
isDirty: (id) => get().dirtyPosts.has(id),
|
|
|
|
// Error modal actions
|
|
showErrorModal: (error) => set({ errorModal: error }),
|
|
hideErrorModal: () => set({ errorModal: null }),
|
|
|
|
// Confirm delete modal actions
|
|
showConfirmDeleteModal: (details) => set({ confirmDeleteModal: details }),
|
|
hideConfirmDeleteModal: () => set({ confirmDeleteModal: null }),
|
|
|
|
// Media Actions
|
|
setMedia: (media) => set({ media }),
|
|
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
|
|
updateMedia: (id, updatedMedia) => set((state) => ({
|
|
media: state.media.map((m) => (m.id === id ? { ...m, ...updatedMedia } : m)),
|
|
})),
|
|
removeMedia: (id) => set((state) => ({
|
|
media: state.media.filter((m) => m.id !== id),
|
|
selectedMediaId: state.selectedMediaId === id ? null : state.selectedMediaId,
|
|
})),
|
|
|
|
// Task Actions
|
|
setTasks: (tasks) => set({ tasks }),
|
|
updateTask: (taskId, task) => set((state) => {
|
|
const exists = state.tasks.some((t) => t.taskId === taskId);
|
|
if (exists) {
|
|
return { tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)) };
|
|
}
|
|
// Add new task if it doesn't exist yet
|
|
return { tasks: [...state.tasks, { taskId, status: 'running', progress: 0, message: '', startTime: new Date().toISOString(), ...task } as TaskProgress] };
|
|
}),
|
|
|
|
// Loading Actions
|
|
setLoading: (isLoading) => set({ isLoading }),
|
|
setError: (error) => set({ error }),
|
|
}),
|
|
{
|
|
name: STORAGE_KEY,
|
|
// Only persist UI state, not data (which is loaded from backend)
|
|
partialize: (state) => ({
|
|
activeView: state.activeView,
|
|
sidebarVisible: state.sidebarVisible,
|
|
panelVisible: state.panelVisible,
|
|
selectedPostId: state.selectedPostId,
|
|
selectedMediaId: state.selectedMediaId,
|
|
preferredEditorMode: state.preferredEditorMode,
|
|
// Tabs are persisted here for now (project-specific persistence handled separately)
|
|
tabs: state.tabs,
|
|
activeTabId: state.activeTabId,
|
|
// Convert Set to array for storage
|
|
dirtyPosts: [...state.dirtyPosts],
|
|
}),
|
|
// Merge function to restore Set from array
|
|
merge: (persisted, current) => {
|
|
const persistedState = persisted as Partial<AppState> & { dirtyPosts?: string[]; tabs?: Tab[] };
|
|
return {
|
|
...current,
|
|
...persistedState,
|
|
tabs: persistedState.tabs || [],
|
|
activeTabId: persistedState.activeTabId || null,
|
|
dirtyPosts: new Set(persistedState.dirtyPosts || []),
|
|
};
|
|
},
|
|
}
|
|
)
|
|
);
|