@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
843 lines (729 loc) • 23.7 kB
text/typescript
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { devtools } from "zustand/middleware";
import type {
BaseSection,
EditorConfig,
EditorState,
ClipboardData,
HistoryEntry,
ValidationError,
} from "./types";
import { validateSection, formatValidationErrors } from "./validation";
import {
createAutoSaveService,
sectionApi,
publishingApi,
handleApiError,
localBackupService,
type AutoSaveService,
type DraftState,
type PublishResponse,
} from "../services/api";
// State interface 확장
interface CMSEditorState extends EditorState {
// Form validation
validationErrors: Record<string, ValidationError[]>;
isValidating: boolean;
// Auto-save and API
autoSaveService: AutoSaveService | null;
autoSaveTimer: NodeJS.Timeout | null;
lastSaved: Date | null;
isSaving: boolean;
saveError: string | null;
// Publishing
isPublishing: boolean;
isPublished: boolean;
publishedAt: Date | null;
publishError: string | null;
draftState: DraftState | null;
// Optimistic updates
pendingUpdates: Set<string>;
// Editor configuration
config: EditorConfig;
// Clipboard
clipboard: ClipboardData | null;
// Network state
isOnline: boolean;
lastSync: Date | null;
syncError: string | null;
}
// Actions interface
interface CMSEditorActions {
// Section management
setSections: (sections: BaseSection[]) => void;
addSection: (section: BaseSection) => void;
updateSection: (sectionId: string, updates: Partial<BaseSection>) => void;
deleteSection: (sectionId: string) => void;
duplicateSection: (sectionId: string) => void;
reorderSections: (startIndex: number, endIndex: number) => void;
// API section operations
loadSections: () => Promise<void>;
createSectionAPI: (
section: Omit<BaseSection, "id" | "metadata">
) => Promise<void>;
updateSectionAPI: (
sectionId: string,
updates: Partial<BaseSection>
) => Promise<void>;
deleteSectionAPI: (sectionId: string) => Promise<void>;
saveSectionsAPI: () => Promise<void>;
// Selection management
selectSection: (sectionId: string | null) => void;
selectElement: (elementId: string | null) => void;
// Validation
validateSections: () => void;
validateSectionById: (sectionId: string) => void;
clearValidationErrors: (sectionId?: string) => void;
// History management
undo: () => void;
redo: () => void;
addHistoryEntry: (
action: HistoryEntry["action"],
target: HistoryEntry["target"],
before: BaseSection | null,
after: BaseSection | null
) => void;
clearHistory: () => void;
// Clipboard
copySection: (sectionId: string) => void;
pasteSection: () => void;
clearClipboard: () => void;
// Auto-save
enableAutoSave: () => void;
disableAutoSave: () => void;
saveNow: () => Promise<void>;
onSaveStart: () => void;
onSaveSuccess: (timestamp: Date) => void;
onSaveError: (error: Error) => void;
// Publishing workflow
saveDraft: () => Promise<void>;
loadDraft: () => Promise<void>;
publishSections: () => Promise<void>;
unpublishSections: () => Promise<void>;
getPreviewUrl: () => Promise<string>;
// Optimistic updates
startOptimisticUpdate: (sectionId: string) => void;
finishOptimisticUpdate: (sectionId: string, success: boolean) => void;
rollbackOptimisticUpdate: (sectionId: string) => void;
// Configuration
updateConfig: (config: Partial<EditorConfig>) => void;
// Loading states
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setSaving: (saving: boolean) => void;
setPublishing: (publishing: boolean) => void;
setSyncError: (error: string | null) => void;
// Network status
setOnlineStatus: (online: boolean) => void;
// Reset
resetEditor: () => void;
}
type CMSEditorStore = CMSEditorState & CMSEditorActions;
// Default configuration
const defaultConfig: EditorConfig = {
autoSave: true,
autoSaveInterval: 5000,
enableHistory: true,
maxHistoryEntries: 50,
enableGrid: true,
gridSize: 8,
showOutlines: false,
showGuidelines: true,
enableKeyboardShortcuts: true,
previewMode: "desktop",
enableDragAndDrop: true,
enableCopyPaste: true,
enableLiveEditing: true,
enableCollaboration: false,
};
// Initial state
const initialState: CMSEditorState = {
sections: [],
selectedSection: null,
selectedElement: null,
clipboard: null,
history: [],
historyIndex: -1,
isDirty: false,
isLoading: false,
error: null,
validationErrors: {},
isValidating: false,
autoSaveService: null,
autoSaveTimer: null,
lastSaved: null,
isSaving: false,
saveError: null,
isPublishing: false,
isPublished: false,
publishedAt: null,
publishError: null,
draftState: null,
pendingUpdates: new Set(),
config: defaultConfig,
isOnline: true,
lastSync: null,
syncError: null,
};
// Utility functions
const generateId = () =>
`section_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
export const useCMSEditor = create<CMSEditorStore>()(
devtools(
immer((set, get) => ({
...initialState,
// Section management
setSections: (sections) =>
set((state) => {
state.sections = sections;
state.isDirty = false;
}),
addSection: (section) =>
set((state) => {
const newSection = { ...section, id: section.id || generateId() };
state.sections.push(newSection);
state.isDirty = true;
// Add history entry
const historyEntry: HistoryEntry = {
id: generateId(),
action: "add",
target: "section",
before: null,
after: newSection,
timestamp: new Date(),
};
if (state.config.enableHistory) {
// Remove any entries after current index
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(historyEntry);
// Limit history size
if (state.history.length > state.config.maxHistoryEntries) {
state.history = state.history.slice(
-state.config.maxHistoryEntries
);
}
state.historyIndex = state.history.length - 1;
}
}),
updateSection: (sectionId, updates) =>
set((state) => {
const index = state.sections.findIndex((s) => s.id === sectionId);
if (index === -1) return;
const before = { ...state.sections[index] };
// Apply updates
Object.assign(state.sections[index], updates);
state.sections[index].metadata.updatedAt = new Date();
state.sections[index].metadata.version += 1;
state.isDirty = true;
// Add history entry
if (state.config.enableHistory) {
const historyEntry: HistoryEntry = {
id: generateId(),
action: "update",
target: "section",
before,
after: { ...state.sections[index] },
timestamp: new Date(),
};
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(historyEntry);
if (state.history.length > state.config.maxHistoryEntries) {
state.history = state.history.slice(
-state.config.maxHistoryEntries
);
}
state.historyIndex = state.history.length - 1;
}
// Clear validation errors for this section
delete state.validationErrors[sectionId];
}),
deleteSection: (sectionId) =>
set((state) => {
const index = state.sections.findIndex((s) => s.id === sectionId);
if (index === -1) return;
const deletedSection = state.sections[index];
state.sections.splice(index, 1);
state.isDirty = true;
// Clear selection if deleted section was selected
if (state.selectedSection === sectionId) {
state.selectedSection = null;
}
// Add history entry
if (state.config.enableHistory) {
const historyEntry: HistoryEntry = {
id: generateId(),
action: "delete",
target: "section",
before: deletedSection,
after: null,
timestamp: new Date(),
};
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(historyEntry);
if (state.history.length > state.config.maxHistoryEntries) {
state.history = state.history.slice(
-state.config.maxHistoryEntries
);
}
state.historyIndex = state.history.length - 1;
}
// Clear validation errors
delete state.validationErrors[sectionId];
}),
duplicateSection: (sectionId) =>
set((state) => {
const section = state.sections.find((s) => s.id === sectionId);
if (!section) return;
const newSection: BaseSection = {
...section,
id: generateId(),
name: `${section.name} (복사본)`,
order: Math.max(...state.sections.map((s) => s.order)) + 1,
metadata: {
...section.metadata,
createdAt: new Date(),
updatedAt: new Date(),
version: 1,
},
};
state.sections.push(newSection);
state.isDirty = true;
}),
reorderSections: (startIndex, endIndex) =>
set((state) => {
const sections = [...state.sections];
const [removed] = sections.splice(startIndex, 1);
sections.splice(endIndex, 0, removed);
// Update order values
sections.forEach((section, index) => {
section.order = index;
});
state.sections = sections;
state.isDirty = true;
}),
// Selection management
selectSection: (sectionId) =>
set((state) => {
state.selectedSection = sectionId;
state.selectedElement = null;
}),
selectElement: (elementId) =>
set((state) => {
state.selectedElement = elementId;
}),
// Validation
validateSections: () =>
set((state) => {
state.isValidating = true;
state.validationErrors = {};
state.sections.forEach((section) => {
const result = validateSection(section);
if (!result.success) {
state.validationErrors[section.id] = formatValidationErrors(
result.error
);
}
});
state.isValidating = false;
}),
validateSectionById: (sectionId) =>
set((state) => {
const section = state.sections.find((s) => s.id === sectionId);
if (!section) return;
const result = validateSection(section);
if (!result.success) {
state.validationErrors[sectionId] = formatValidationErrors(
result.error
);
} else {
delete state.validationErrors[sectionId];
}
}),
clearValidationErrors: (sectionId) =>
set((state) => {
if (sectionId) {
delete state.validationErrors[sectionId];
} else {
state.validationErrors = {};
}
}),
// History management
undo: () =>
set((state) => {
if (state.historyIndex < 0) return;
const entry = state.history[state.historyIndex];
if (entry.action === "add" && entry.after) {
// Remove the added section
const index = state.sections.findIndex(
(s) => s.id === entry.after!.id
);
if (index !== -1) {
state.sections.splice(index, 1);
}
} else if (entry.action === "delete" && entry.before) {
// Restore the deleted section
state.sections.push(entry.before);
} else if (entry.action === "update" && entry.before) {
// Restore the previous version
const index = state.sections.findIndex(
(s) => s.id === entry.before!.id
);
if (index !== -1) {
state.sections[index] = { ...entry.before };
}
}
state.historyIndex--;
state.isDirty = true;
}),
redo: () =>
set((state) => {
if (state.historyIndex >= state.history.length - 1) return;
state.historyIndex++;
const entry = state.history[state.historyIndex];
if (entry.action === "add" && entry.after) {
// Re-add the section
state.sections.push(entry.after);
} else if (entry.action === "delete" && entry.before) {
// Re-delete the section
const index = state.sections.findIndex(
(s) => s.id === entry.before!.id
);
if (index !== -1) {
state.sections.splice(index, 1);
}
} else if (entry.action === "update" && entry.after) {
// Re-apply the update
const index = state.sections.findIndex(
(s) => s.id === entry.after!.id
);
if (index !== -1) {
state.sections[index] = { ...entry.after };
}
}
state.isDirty = true;
}),
addHistoryEntry: (action, target, before, after) =>
set((state) => {
if (!state.config.enableHistory) return;
const entry: HistoryEntry = {
id: generateId(),
action,
target,
before,
after,
timestamp: new Date(),
};
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(entry);
if (state.history.length > state.config.maxHistoryEntries) {
state.history = state.history.slice(
-state.config.maxHistoryEntries
);
}
state.historyIndex = state.history.length - 1;
}),
clearHistory: () =>
set((state) => {
state.history = [];
state.historyIndex = -1;
}),
// Clipboard
copySection: (sectionId) =>
set((state) => {
const section = state.sections.find((s) => s.id === sectionId);
if (!section) return;
state.clipboard = {
type: "section",
data: section,
timestamp: new Date(),
};
}),
pasteSection: () =>
set((state) => {
if (!state.clipboard || state.clipboard.type !== "section") return;
const section = state.clipboard.data as BaseSection;
const newSection: BaseSection = {
...section,
id: generateId(),
name: `${section.name} (붙여넣기)`,
order: Math.max(...state.sections.map((s) => s.order)) + 1,
metadata: {
...section.metadata,
createdAt: new Date(),
updatedAt: new Date(),
version: 1,
},
};
state.sections.push(newSection);
state.isDirty = true;
}),
clearClipboard: () =>
set((state) => {
state.clipboard = null;
}),
// Auto-save
enableAutoSave: () => {
const { config } = get();
if (config.autoSave) {
const autoSaveService = createAutoSaveService(
get().onSaveStart,
get().onSaveSuccess,
get().onSaveError
);
set((state) => {
state.autoSaveService = autoSaveService;
});
// Start local backup
localBackupService.startBackup(() => get().sections);
}
},
disableAutoSave: () => {
const { autoSaveService } = get();
if (autoSaveService) {
autoSaveService.destroy();
set((state) => {
state.autoSaveService = null;
});
}
// Stop local backup
localBackupService.stopBackup();
},
saveNow: async () => {
const { autoSaveService, sections } = get();
if (autoSaveService) {
await autoSaveService.saveNow(sections);
}
},
onSaveStart: () =>
set((state) => {
state.isSaving = true;
state.saveError = null;
}),
onSaveSuccess: (timestamp) =>
set((state) => {
state.isSaving = false;
state.lastSaved = timestamp;
state.isDirty = false;
state.lastSync = timestamp;
}),
onSaveError: (error) =>
set((state) => {
state.isSaving = false;
state.saveError = error.message;
state.syncError = error.message;
}),
// Publishing workflow
saveDraft: async () => {
const { sections } = get();
set((state) => {
state.isSaving = true;
state.saveError = null;
});
try {
const draftState = await publishingApi.saveDraft(sections);
set((state) => {
state.isSaving = false;
state.draftState = draftState;
state.lastSaved = new Date();
state.isDirty = false;
});
} catch (error) {
const errorMessage = handleApiError(error);
set((state) => {
state.isSaving = false;
state.saveError = errorMessage;
});
throw error;
}
},
loadDraft: async () => {
try {
const draftState = await publishingApi.getDraft();
if (draftState) {
set((state) => {
state.sections = draftState.sections;
state.draftState = draftState;
state.isDirty = false;
state.lastSaved = new Date(draftState.lastSaved);
});
}
} catch (error) {
const errorMessage = handleApiError(error);
set((state) => {
state.error = errorMessage;
});
}
},
publishSections: async () => {
const { sections } = get();
set((state) => {
state.isPublishing = true;
state.publishError = null;
});
try {
const result = await publishingApi.publishSections(sections);
set((state) => {
state.isPublishing = false;
state.isPublished = true;
state.publishedAt = new Date(result.publishedAt);
state.isDirty = false;
});
} catch (error) {
const errorMessage = handleApiError(error);
set((state) => {
state.isPublishing = false;
state.publishError = errorMessage;
});
throw error;
}
},
unpublishSections: async () => {
set((state) => {
state.isPublishing = true;
state.publishError = null;
});
try {
await publishingApi.unpublishSections();
set((state) => {
state.isPublishing = false;
state.isPublished = false;
state.publishedAt = null;
});
} catch (error) {
const errorMessage = handleApiError(error);
set((state) => {
state.isPublishing = false;
state.publishError = errorMessage;
});
throw error;
}
},
getPreviewUrl: async () => {
try {
return await publishingApi.getPreviewUrl();
} catch (error) {
const errorMessage = handleApiError(error);
set((state) => {
state.error = errorMessage;
});
throw error;
}
},
// Optimistic updates
startOptimisticUpdate: (sectionId) =>
set((state) => {
state.pendingUpdates.add(sectionId);
}),
finishOptimisticUpdate: (sectionId, success) =>
set((state) => {
state.pendingUpdates.delete(sectionId);
if (!success) {
// Rollback logic would go here
console.warn(`Optimistic update failed for section ${sectionId}`);
}
}),
rollbackOptimisticUpdate: (sectionId) =>
set((state) => {
state.pendingUpdates.delete(sectionId);
// Implement rollback logic
}),
// Configuration
updateConfig: (config) =>
set((state) => {
state.config = { ...state.config, ...config };
}),
// Loading states
setLoading: (loading) =>
set((state) => {
state.isLoading = loading;
}),
setError: (error) =>
set((state) => {
state.error = error;
}),
setSaving: (saving) =>
set((state) => {
state.isSaving = saving;
}),
setPublishing: (publishing) =>
set((state) => {
state.isPublishing = publishing;
}),
setSyncError: (error) =>
set((state) => {
state.syncError = error;
}),
// Network status
setOnlineStatus: (online) =>
set((state) => {
state.isOnline = online;
if (online && state.syncError) {
// Try to sync when coming back online
get().validateSections();
}
}),
// Reset
resetEditor: () =>
set((state) => {
// Cleanup auto-save
if (state.autoSaveService) {
state.autoSaveService.destroy();
}
localBackupService.stopBackup();
// Reset state
Object.assign(state, {
sections: [],
selectedSection: null,
selectedElement: null,
clipboard: null,
history: [],
historyIndex: -1,
isDirty: false,
isLoading: false,
error: null,
validationErrors: {},
isValidating: false,
autoSaveService: null,
autoSaveTimer: null,
lastSaved: null,
isSaving: false,
saveError: null,
isPublishing: false,
isPublished: false,
publishedAt: null,
publishError: null,
draftState: null,
pendingUpdates: new Set(),
config: defaultConfig,
isOnline: true,
lastSync: null,
syncError: null,
});
}),
})),
{ name: "cms-editor-store" }
)
);
// Computed selectors
export const useEditorSelectors = () => {
const store = useCMSEditor();
return {
canUndo: store.historyIndex >= 0,
canRedo: store.historyIndex < store.history.length - 1,
hasValidationErrors: Object.keys(store.validationErrors).length > 0,
totalValidationErrors: Object.values(store.validationErrors).reduce(
(total, errors) => total + errors.length,
0
),
hasUnsavedChanges: store.isDirty,
isAutoSaveActive: store.autoSaveService?.isActive || false,
isAutoSavePending: store.autoSaveService?.isPending || false,
canPublish:
store.sections.length > 0 && !store.isPublishing && !store.isSaving,
hasClipboard: store.clipboard !== null,
selectedSectionData: store.selectedSection
? store.sections.find((s) => s.id === store.selectedSection)
: null,
};
};