"use client" import * as React from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { AlertTriangle, Save, Mail, RefreshCw, Settings2, Code2, Lightbulb, Moon, Sun } from "lucide-react" import { toast } from "sonner" import { type TemplateWithVariables } from "@/db/schema" import { previewTemplateAction, updateTemplateAction } from "../service" import { useSession } from "next-auth/react" import { HtmlCodeEditor } from "@/components/template-editor/html-code-editor" import { formatHtml } from "@/lib/utils" interface TemplateContentEditorProps { template: TemplateWithVariables onUpdate: (template: TemplateWithVariables) => void } export function TemplateContentEditor({ template, onUpdate }: TemplateContentEditorProps) { const [content, setContent] = React.useState(() => { return formatHtml(template.content) }) const [subject, setSubject] = React.useState(template.subject || '') const [isLoading, setIsLoading] = React.useState(false) const [isPreviewLoading, setIsPreviewLoading] = React.useState(false) const [previewHtml, setPreviewHtml] = React.useState("") const [previewSubject, setPreviewSubject] = React.useState("") const [validationErrors, setValidationErrors] = React.useState([]) const [sampleData, setSampleData] = React.useState(template.sampleData || {}) const [autoPreview, setAutoPreview] = React.useState(true) const [darkMode, setDarkMode] = React.useState(false) // 다크모드 상태 추가 const { data: session } = useSession(); React.useEffect(() => { if (template.content !== content) { const formatted = formatHtml(template.content) setContent(formatted) } }, [template.content]) // HtmlCodeEditor ref const editorRef = React.useRef<{ focus: () => void insertText: (text: string) => void getEditor: () => any }>(null) // 자동 미리보기 (디바운스) - 시간 늘림 React.useEffect(() => { if (!autoPreview) return const timer = setTimeout(() => { handlePreview(true) }, 1500) // 1.5초로 증가 return () => clearTimeout(timer) }, [content, subject, sampleData, autoPreview]) // 실시간 검증 React.useEffect(() => { validateContent(content, subject) }, [content, subject]) // 변수 사용량 분석 const analyzeVariableUsage = React.useCallback(() => { const templateVars = template.variables.map(v => v.variableName) const usedVars = new Set() const combinedText = content + ' ' + subject const matches = combinedText.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g) if (matches) { matches.forEach(match => { const varName = match.replace(/\{\{\s*|\s*\}\}/g, '') usedVars.add(varName) }) } const unusedVars = templateVars.filter(v => !usedVars.has(v)) const undefinedVars = Array.from(usedVars).filter(v => !templateVars.includes(v) && !['t', 'siteName', 'companyName', 'i18n'].includes(v) ) return { unusedVars, undefinedVars, usedVars: Array.from(usedVars) } }, [content, subject, template.variables]) // 검증 함수 const validateContent = (content: string, subject: string) => { const errors: string[] = [] if (subject.length > 200) { errors.push('이메일 제목이 너무 깁니다 (200자 초과)') } const forbiddenChars = ['\n', '\r', '\t'] forbiddenChars.forEach(char => { if (subject.includes(char)) { errors.push('이메일 제목에 개행 문자나 탭 문자를 사용할 수 없습니다') } }) // HTML 기본 검증 추가 if (content.trim()) { try { const parser = new DOMParser() const doc = parser.parseFromString(`
${content}
`, 'text/html') const parseError = doc.querySelector('parsererror') if (parseError) { errors.push('유효하지 않은 HTML 구조가 감지되었습니다.') } } catch (error) { errors.push('HTML 구문 분석 중 오류가 발생했습니다.') } } const dangerousPatterns = [ /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi, /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi, ] const combinedText = content + ' ' + subject for (const pattern of dangerousPatterns) { if (pattern.test(combinedText)) { errors.push('보안상 위험한 구문이 감지되었습니다.') break } } setValidationErrors(errors) } // 저장 함수 const handleSave = async () => { if (validationErrors.length > 0) { toast.error('검증 오류를 먼저 해결해주세요.') return } setIsLoading(true) try { if (!session?.user?.id) { toast.error("로그인이 필요합니다") return } const result = await updateTemplateAction(template.slug, { subject, content, sampleData, updatedBy: Number(session.user.id) }) if (result.success) { toast.success('템플릿이 저장되었습니다.') onUpdate({ ...template, subject, content, sampleData, version: template.version ? template.version + 1 :0 }) } else { toast.error(result.error || '저장에 실패했습니다.') } } catch (error) { toast.error('저장 중 오류가 발생했습니다.') } finally { setIsLoading(false) } } // 미리보기 생성 const handlePreview = async (silent = false) => { if (!silent) setIsPreviewLoading(true) try { const result = await previewTemplateAction( template.slug, sampleData, content, subject // 주석 해제 ) if (result.success && result.data) { setPreviewHtml(result.data.html) setPreviewSubject(result.data.subjectHtml) if (!silent) toast.success('미리보기가 생성되었습니다.') } else { if (!silent) toast.error(result.error || '미리보기 생성에 실패했습니다.') } } catch (error) { if (!silent) toast.error('미리보기 생성 중 오류가 발생했습니다.') } finally { if (!silent) setIsPreviewLoading(false) } } // 변수 삽입 함수 - HtmlCodeEditor 지원 const insertVariable = (variableName: string, targetField: 'subject' | 'content') => { const variable = `{{${variableName}}}` if (targetField === 'subject') { const input = document.querySelector('input[data-subject-input]') as HTMLInputElement if (input) { const start = input.selectionStart || 0 const end = input.selectionEnd || 0 const newValue = subject.substring(0, start) + variable + subject.substring(end) setSubject(newValue) setTimeout(() => { input.focus() input.setSelectionRange(start + variable.length, start + variable.length) }, 0) } } else { // HtmlCodeEditor에 변수 삽입 if (editorRef.current) { editorRef.current.insertText(variable) } } } // HTML 템플릿 스니펫 삽입 - HtmlCodeEditor 지원 const insertHtmlSnippet = (snippet: string) => { if (editorRef.current) { editorRef.current.insertText(snippet) } } // 샘플 데이터 업데이트 const updateSampleData = (key: string, value: any) => { const newSampleData = { ...sampleData, [key]: value } setSampleData(newSampleData) } const variableAnalysis = analyzeVariableUsage() // HTML 스니펫들 - 개행 추가로 더 깔끔하게 const htmlSnippets = [ { name: '버튼', snippet: `\n
{{buttonText}}
\n` }, { name: '구분선', snippet: `\n
\n` }, { name: '박스', snippet: `\n
{{content}}
\n` }, { name: '이미지', snippet: `\n
{{imageAlt}}
\n` }, { name: '2열 레이아웃', snippet: `\n
{{leftContent}} {{rightContent}}
\n` }, { name: '헤더', snippet: `\n

{{title}}

\n` } ] return (
{/* 왼쪽: 편집 영역 */}
{/* 상태 표시 */}
{validationErrors.length > 0 && ( {validationErrors.length}개 오류 )} {variableAnalysis.undefinedVars.length > 0 && ( {variableAnalysis.undefinedVars.length}개 미정의 변수 )} {variableAnalysis.usedVars.length}/{template.variables.length} 변수 사용 중
{/* 오류 표시 */} {validationErrors.length > 0 && (

검증 오류

    {validationErrors.map((error, index) => (
  • • {error}
  • ))}
)} {/* 이메일 제목 편집 */} 이메일 제목
setSubject(e.target.value)} placeholder="예: {{userName}}님의 {{type}} 알림" className="font-mono" />
{template.variables.map(variable => ( insertVariable(variable.variableName, 'subject')} > {variable.variableName} ))}
{/* 변수 분석 결과 */} {(variableAnalysis.undefinedVars.length > 0 || variableAnalysis.unusedVars.length > 0) && (

변수 사용 분석

{variableAnalysis.undefinedVars.length > 0 && (

미정의 변수

{variableAnalysis.undefinedVars.map(varName => ( {varName} ))}
)} {variableAnalysis.unusedVars.length > 0 && (

미사용 변수

{variableAnalysis.unusedVars.map(varName => ( {varName} ))}
)}
)} {/* HTML 스니펫 도구 */} 빠른 HTML 삽입
{htmlSnippets.map((snippet, index) => ( ))}
{/* HtmlCodeEditor로 교체된 HTML 편집기 */} HTML 템플릿 편집 {/* HtmlCodeEditor 사용 */}
{/* 변수 삽입 */}

변수 삽입

{template.variables.map(variable => ( insertVariable(variable.variableName, 'content')} > {variable.variableName} ))}
{/* 도움말 */}

이메일 HTML 팁

  • • 테이블 기반 레이아웃 사용
  • • 인라인 CSS 스타일 권장
  • • max-width: 600px 권장
  • • 이미지에 alt 속성 필수
{/* 오른쪽: 미리보기 영역 */}
{/* 샘플 데이터 편집 */} 샘플 데이터 {template.variables.length > 0 ? ( <> {template.variables.map(variable => (
updateSampleData(variable.variableName, e.target.value)} placeholder={variable.description || `${variable.variableName} 값 입력`} className="text-sm" />
))} ) : (

변수가 없습니다.

변수 관리 탭에서 변수를 추가하세요.

)}
{/* 미리보기 결과 */} 미리보기 {/* 제목 미리보기 */} {previewSubject && (

{previewSubject}

)} {/* 내용 미리보기 */} {previewHtml ? (