UNPKG

@restnfeel/agentc-starter-kit

Version:

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

539 lines (462 loc) 13.5 kB
import type { BaseSection } from "../editor/types"; // API Configuration const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api"; const API_TIMEOUT = 10000; // 10 seconds // API Response Types export interface ApiResponse<T = unknown> { success: boolean; data?: T; error?: string; message?: string; } export interface SectionListResponse { sections: BaseSection[]; totalCount: number; page: number; pageSize: number; lastModified: string; } export interface PublishResponse { success: boolean; publishedAt: string; version: number; url?: string; } export interface DraftState { id: string; sections: BaseSection[]; lastSaved: string; isDraft: boolean; version: number; } // Error Types export class ApiError extends Error { constructor(message: string, public status: number, public code?: string) { super(message); this.name = "ApiError"; } } export class NetworkError extends Error { constructor(message: string = "네트워크 연결을 확인해주세요") { super(message); this.name = "NetworkError"; } } // HTTP Client with retry and timeout class HttpClient { private async request<T>( url: string, options: RequestInit = {}, retries = 2 ): Promise<T> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT); const config: RequestInit = { ...options, signal: controller.signal, headers: { "Content-Type": "application/json", ...options.headers, }, }; try { const response = await fetch(`${API_BASE_URL}${url}`, config); clearTimeout(timeoutId); if (!response.ok) { let errorMessage = `HTTP ${response.status}`; try { const errorData = await response.json(); errorMessage = errorData.message || errorData.error || errorMessage; } catch { // JSON parsing failed, use default message } throw new ApiError(errorMessage, response.status); } return await response.json(); } catch (error: unknown) { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { throw new NetworkError("요청 시간이 초과되었습니다"); } if (error instanceof ApiError) { throw error; } // Network error - retry if retries remaining if (retries > 0) { await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1s before retry return this.request<T>(url, options, retries - 1); } throw new NetworkError(); } } async get<T>(url: string): Promise<T> { return this.request<T>(url, { method: "GET" }); } async post<T>(url: string, data?: unknown): Promise<T> { return this.request<T>(url, { method: "POST", body: data ? JSON.stringify(data) : undefined, }); } async put<T>(url: string, data?: unknown): Promise<T> { return this.request<T>(url, { method: "PUT", body: data ? JSON.stringify(data) : undefined, }); } async patch<T>(url: string, data?: unknown): Promise<T> { return this.request<T>(url, { method: "PATCH", body: data ? JSON.stringify(data) : undefined, }); } async delete<T>(url: string): Promise<T> { return this.request<T>(url, { method: "DELETE" }); } } const httpClient = new HttpClient(); // Section API Service export class SectionApiService { // Get all sections async getSections(page = 1, pageSize = 50): Promise<SectionListResponse> { return httpClient.get<SectionListResponse>( `/sections?page=${page}&pageSize=${pageSize}` ); } // Get single section async getSection(sectionId: string): Promise<BaseSection> { const response = await httpClient.get<ApiResponse<BaseSection>>( `/sections/${sectionId}` ); if (!response.success || !response.data) { throw new ApiError(response.error || "섹션을 찾을 수 없습니다", 404); } return response.data; } // Create new section async createSection( section: Omit<BaseSection, "id" | "metadata"> ): Promise<BaseSection> { const response = await httpClient.post<ApiResponse<BaseSection>>( "/sections", { ...section, metadata: { createdAt: new Date(), updatedAt: new Date(), createdBy: "current-user", // TODO: Get from auth updatedBy: "current-user", version: 1, }, } ); if (!response.success || !response.data) { throw new ApiError(response.error || "섹션 생성에 실패했습니다", 400); } return response.data; } // Update section async updateSection( sectionId: string, updates: Partial<BaseSection> ): Promise<BaseSection> { const response = await httpClient.patch<ApiResponse<BaseSection>>( `/sections/${sectionId}`, { ...updates, metadata: { ...updates.metadata, updatedAt: new Date(), updatedBy: "current-user", // TODO: Get from auth version: (updates.metadata?.version || 1) + 1, }, } ); if (!response.success || !response.data) { throw new ApiError(response.error || "섹션 업데이트에 실패했습니다", 400); } return response.data; } // Delete section async deleteSection(sectionId: string): Promise<void> { const response = await httpClient.delete<ApiResponse>( `/sections/${sectionId}` ); if (!response.success) { throw new ApiError(response.error || "섹션 삭제에 실패했습니다", 400); } } // Bulk update sections async updateSections(sections: BaseSection[]): Promise<BaseSection[]> { const response = await httpClient.put<ApiResponse<BaseSection[]>>( "/sections/bulk", { sections: sections.map((section) => ({ ...section, metadata: { ...section.metadata, updatedAt: new Date(), updatedBy: "current-user", version: section.metadata.version + 1, }, })), } ); if (!response.success || !response.data) { throw new ApiError( response.error || "섹션 일괄 업데이트에 실패했습니다", 400 ); } return response.data; } // Reorder sections async reorderSections( sectionOrders: Array<{ id: string; order: number }> ): Promise<void> { const response = await httpClient.patch<ApiResponse>("/sections/reorder", { sectionOrders, }); if (!response.success) { throw new ApiError( response.error || "섹션 순서 변경에 실패했습니다", 400 ); } } } // Draft and Publishing API Service export class PublishingApiService { // Save as draft async saveDraft(sections: BaseSection[]): Promise<DraftState> { const response = await httpClient.post<ApiResponse<DraftState>>("/drafts", { sections, isDraft: true, }); if (!response.success || !response.data) { throw new ApiError(response.error || "초안 저장에 실패했습니다", 400); } return response.data; } // Get draft async getDraft(): Promise<DraftState | null> { try { const response = await httpClient.get<ApiResponse<DraftState>>( "/drafts/current" ); return response.success && response.data ? response.data : null; } catch (error) { if (error instanceof ApiError && error.status === 404) { return null; // No draft exists } throw error; } } // Publish sections async publishSections(sections: BaseSection[]): Promise<PublishResponse> { const response = await httpClient.post<ApiResponse<PublishResponse>>( "/publish", { sections, } ); if (!response.success || !response.data) { throw new ApiError(response.error || "발행에 실패했습니다", 400); } return response.data; } // Unpublish (revert to draft) async unpublishSections(): Promise<void> { const response = await httpClient.post<ApiResponse>("/unpublish"); if (!response.success) { throw new ApiError(response.error || "발행 취소에 실패했습니다", 400); } } // Get published version async getPublishedSections(): Promise<BaseSection[]> { const response = await httpClient.get<ApiResponse<BaseSection[]>>( "/published" ); if (!response.success || !response.data) { throw new ApiError( response.error || "발행된 섹션을 가져올 수 없습니다", 404 ); } return response.data; } // Preview URL async getPreviewUrl(): Promise<string> { const response = await httpClient.get<ApiResponse<{ url: string }>>( "/preview-url" ); if (!response.success || !response.data?.url) { throw new ApiError( response.error || "미리보기 URL을 생성할 수 없습니다", 400 ); } return response.data.url; } } // Auto-save Service export class AutoSaveService { private saveTimer: NodeJS.Timeout | null = null; private pendingSave: BaseSection[] | null = null; private isSaving = false; constructor( private sectionApi: SectionApiService, private publishingApi: PublishingApiService, private onSaveStart: () => void, private onSaveSuccess: (timestamp: Date) => void, private onSaveError: (error: Error) => void ) {} // Schedule auto-save scheduleSave(sections: BaseSection[], delay = 3000) { this.pendingSave = sections; if (this.saveTimer) { clearTimeout(this.saveTimer); } this.saveTimer = setTimeout(async () => { await this.executeSave(); }, delay); } // Execute immediate save async saveNow(sections: BaseSection[]) { this.pendingSave = sections; if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } await this.executeSave(); } // Internal save execution private async executeSave() { if (this.isSaving || !this.pendingSave) { return; } this.isSaving = true; this.onSaveStart(); try { await this.publishingApi.saveDraft(this.pendingSave); this.onSaveSuccess(new Date()); } catch (error) { console.error("Auto-save failed:", error); this.onSaveError( error instanceof Error ? error : new Error("자동 저장 실패") ); } finally { this.isSaving = false; this.pendingSave = null; this.saveTimer = null; } } // Cancel pending save cancelSave() { if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } this.pendingSave = null; } // Check if save is pending get isPending() { return this.saveTimer !== null; } // Check if currently saving get isActive() { return this.isSaving; } // Cleanup destroy() { this.cancelSave(); } } // Export service instances export const sectionApi = new SectionApiService(); export const publishingApi = new PublishingApiService(); // Factory function for auto-save service export const createAutoSaveService = ( onSaveStart: () => void, onSaveSuccess: (timestamp: Date) => void, onSaveError: (error: Error) => void ) => { return new AutoSaveService( sectionApi, publishingApi, onSaveStart, onSaveSuccess, onSaveError ); }; // Utility functions for error handling export const handleApiError = (error: unknown): string => { if (error instanceof ApiError) { return error.message; } if (error instanceof NetworkError) { return error.message; } if (error instanceof Error) { return error.message; } return "알 수 없는 오류가 발생했습니다"; }; export const isRetryableError = (error: unknown): boolean => { if (error instanceof NetworkError) { return true; } if (error instanceof ApiError) { // Retry on server errors (5xx) but not client errors (4xx) return error.status >= 500; } return false; }; // Local storage backup for offline scenarios export class LocalBackupService { private readonly STORAGE_KEY = "cms-sections-backup"; private readonly BACKUP_INTERVAL = 30000; // 30 seconds private backupTimer: NodeJS.Timeout | null = null; startBackup(getSections: () => BaseSection[]) { this.backupTimer = setInterval(() => { try { const sections = getSections(); const backup = { sections, timestamp: new Date().toISOString(), version: Date.now(), }; localStorage.setItem(this.STORAGE_KEY, JSON.stringify(backup)); } catch (error) { console.warn("Local backup failed:", error); } }, this.BACKUP_INTERVAL); } stopBackup() { if (this.backupTimer) { clearInterval(this.backupTimer); this.backupTimer = null; } } getBackup(): { sections: BaseSection[]; timestamp: string; version: number; } | null { try { const backup = localStorage.getItem(this.STORAGE_KEY); return backup ? JSON.parse(backup) : null; } catch (error) { console.warn("Failed to retrieve backup:", error); return null; } } clearBackup() { try { localStorage.removeItem(this.STORAGE_KEY); } catch (error) { console.warn("Failed to clear backup:", error); } } } export const localBackupService = new LocalBackupService();