UNPKG

@restnfeel/agentc-starter-kit

Version:

한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템

843 lines (729 loc) 23.7 kB
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, }; };