@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
539 lines (462 loc) • 13.5 kB
text/typescript
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();