diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-02 00:45:49 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-02 00:45:49 +0000 |
| commit | 2acf5f8966a40c1c9a97680c8dc263ee3f1ad3d1 (patch) | |
| tree | f406b5c86f563347c7fd088a85fd1a82284dc5ff /components | |
| parent | 6a9ca20deddcdcbe8495cf5a73ec7ea5f53f9b55 (diff) | |
(대표님/최겸) 20250702 변경사항 업데이트
Diffstat (limited to 'components')
| -rw-r--r-- | components/error-boundary.tsx | 45 | ||||
| -rw-r--r-- | components/layout/SessionManager.tsx | 2 | ||||
| -rw-r--r-- | components/qna/comment-section.tsx | 166 | ||||
| -rw-r--r-- | components/qna/richTextEditor.tsx | 676 | ||||
| -rw-r--r-- | components/qna/tiptap-editor.tsx | 287 | ||||
| -rw-r--r-- | components/tech-vendors/tech-vendor-container.tsx | 7 |
6 files changed, 1180 insertions, 3 deletions
diff --git a/components/error-boundary.tsx b/components/error-boundary.tsx new file mode 100644 index 00000000..41334eb7 --- /dev/null +++ b/components/error-boundary.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as React from "react"; + +interface ErrorBoundaryProps { + children: React.ReactNode; + fallback: React.ComponentType<{ error: Error; reset: () => void }>; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("Dashboard error boundary caught an error:", error, errorInfo); + } + + render() { + if (this.state.hasError && this.state.error) { + const Fallback = this.props.fallback; + return ( + <Fallback + error={this.state.error} + reset={() => this.setState({ hasError: false, error: null })} + /> + ); + } + + return this.props.children; + } +}
\ No newline at end of file diff --git a/components/layout/SessionManager.tsx b/components/layout/SessionManager.tsx index c917c5f3..0aca82fb 100644 --- a/components/layout/SessionManager.tsx +++ b/components/layout/SessionManager.tsx @@ -96,7 +96,7 @@ export function SessionManager({ lng }: SessionManagerProps) { const handleAutoLogout = useCallback(() => { setShowExpiredModal(false) setShowWarning(false) - window.location.href = `/${lng}/evcp?reason=expired` + window.location.href = `/${lng}/${session?.user.domain}?reason=expired` }, [lng]) // 세션 만료 체크 diff --git a/components/qna/comment-section.tsx b/components/qna/comment-section.tsx new file mode 100644 index 00000000..2ea358e2 --- /dev/null +++ b/components/qna/comment-section.tsx @@ -0,0 +1,166 @@ +import * as React from "react"; +import { useSession } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { format } from "date-fns"; +import { Comment } from "@/lib/qna/types"; +import { Trash2, Pencil, Check, X } from "lucide-react"; + +interface CommentSectionProps { + answerId: string; + comments: Comment[]; + onAddComment: (content: string) => Promise<void>; + onDeleteComment: (commentId: string) => Promise<void>; + onUpdateComment?: (commentId: string, content: string) => Promise<void>; +} + +export function CommentSection({ answerId, comments, onAddComment, onDeleteComment, onUpdateComment }: CommentSectionProps) { + const { data: session } = useSession(); + const [content, setContent] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [editingId, setEditingId] = React.useState<string | null>(null); + const [editContent, setEditContent] = React.useState(""); + + const handleSubmit = async () => { + if (!content.trim() || !session?.user?.name) return; + setIsSubmitting(true); + try { + await onAddComment(content); + setContent(""); + } finally { + setIsSubmitting(false); + } + }; + + const handleEditStart = (comment: Comment) => { + setEditingId(comment.id); + setEditContent(comment.content); + }; + + const handleEditCancel = () => { + setEditingId(null); + setEditContent(""); + }; + + const handleEditSave = async (commentId: string) => { + if (!editContent.trim() || !onUpdateComment) return; + try { + await onUpdateComment(commentId, editContent); + setEditingId(null); + } catch (error) { + console.error("댓글 수정 실패:", error); + } + }; + + return ( + <div className="space-y-4 mt-4"> + <div className="flex items-center gap-2"> + <h3 className="text-sm font-medium">댓글</h3> + {comments.length > 0 && ( + <Badge variant="secondary" className="text-xs"> + {comments.length} + </Badge> + )} + </div> + + {/* 댓글 목록 */} + <div className="space-y-3"> + {comments.map((comment) => ( + <div key={comment.id} className="flex items-start justify-between text-sm bg-muted/50 rounded-md p-2"> + <div className="flex-1 space-y-1"> + <div className="flex items-center gap-2"> + <span className="font-medium">{comment.author}</span> + <span className="text-xs text-muted-foreground"> + {format(new Date(comment.createdAt), "yyyy.MM.dd HH:mm")} + </span> + </div> + {editingId === comment.id ? ( + <Textarea + value={editContent} + onChange={(e) => setEditContent(e.target.value)} + className="min-h-[60px] text-sm" + maxLength={250} + /> + ) : ( + <p className="text-sm">{comment.content}</p> + )} + </div> + {session?.user?.name === comment.author && ( + <div className="flex gap-2"> + {editingId === comment.id ? ( + <> + <Button + variant="ghost" + size="icon" + className="h-6 w-6 text-green-600 hover:text-green-700" + onClick={() => handleEditSave(comment.id)} + > + <Check className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="icon" + className="h-6 w-6 text-muted-foreground" + onClick={handleEditCancel} + > + <X className="h-4 w-4" /> + </Button> + </> + ) : ( + <> + <Button + variant="ghost" + size="icon" + onClick={() => handleEditStart(comment)} + className="h-6 w-6 text-muted-foreground hover:text-blue-500 transition-colors" + > + <Pencil className="h-3.5 w-3.5" /> + </Button> + <Button + variant="ghost" + size="icon" + className="h-6 w-6 text-muted-foreground hover:text-destructive transition-colors" + onClick={() => onDeleteComment(comment.id)} + > + <Trash2 className="h-3.5 w-3.5" /> + </Button> + </> + )} + </div> + )} + </div> + ))} + </div> + + {/* 댓글 입력 */} + {session?.user && ( + <div className="flex gap-2"> + <Textarea + value={content} + onChange={(e) => { + if (e.target.value.length <= 250) { + setContent(e.target.value); + } + }} + placeholder="댓글을 입력하세요 (250자 이내)" + className="min-h-[80px] flex-1" + maxLength={250} + /> + <Button + onClick={handleSubmit} + disabled={isSubmitting || !content.trim()} + className="self-start" + > + {isSubmitting ? "저장 중..." : "등록"} + </Button> + </div> + )} + + {/* 글자 수 표시 */} + <div className="text-xs text-muted-foreground text-right"> + {content.length}/250 + </div> + </div> + ); +}
\ No newline at end of file diff --git a/components/qna/richTextEditor.tsx b/components/qna/richTextEditor.tsx new file mode 100644 index 00000000..37e07a89 --- /dev/null +++ b/components/qna/richTextEditor.tsx @@ -0,0 +1,676 @@ +import { type Editor } from '@tiptap/react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { + Bold, + Italic, + Underline, + Strikethrough, + ListOrdered, + List, + Quote, + Undo, + Redo, + Link, + Image, + AlignLeft, + AlignCenter, + AlignRight, + AlignJustify, + Subscript, + Superscript, + Table, + Highlighter, + CheckSquare, + Type, +} from 'lucide-react' +import { Toggle } from '../ui/toggle' +import { Separator } from '../ui/separator' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +interface ToolbarProps { + editor: Editor | null; + disabled?: boolean; + onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void; +} + +export function Toolbar({ editor, disabled, onImageUpload }: ToolbarProps) { + // 툴바 상태 관리 + const [toolbarState, setToolbarState] = useState({ + bold: false, + italic: false, + underline: false, + strike: false, + bulletList: false, + orderedList: false, + blockquote: false, + link: false, + highlight: false, + taskList: false, + table: false, + subscript: false, + superscript: false, + heading: false, + textAlign: 'left' as 'left' | 'center' | 'right' | 'justify', + }); + + // 디바운스를 위한 ref + const updateTimeoutRef = useRef<NodeJS.Timeout | null>(null); + + // 툴바 상태 업데이트 함수 (디바운스 적용) + const updateToolbarState = useCallback(() => { + if (!editor) return; + + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + + updateTimeoutRef.current = setTimeout(() => { + try { + const newState = { + bold: editor.isActive('bold'), + italic: editor.isActive('italic'), + underline: editor.isActive('underline'), + strike: editor.isActive('strike'), + bulletList: editor.isActive('bulletList'), + orderedList: editor.isActive('orderedList'), + blockquote: editor.isActive('blockquote'), + link: editor.isActive('link'), + highlight: editor.isActive('highlight'), + taskList: editor.isActive('taskList'), + table: editor.isActive('table'), + subscript: editor.isActive('subscript'), + superscript: editor.isActive('superscript'), + heading: editor.isActive('heading'), + textAlign: editor.isActive({ textAlign: 'center' }) ? 'center' : + editor.isActive({ textAlign: 'right' }) ? 'right' : + editor.isActive({ textAlign: 'justify' }) ? 'justify' : 'left', + }; + + setToolbarState(prevState => { + // 상태가 실제로 변경된 경우에만 업데이트 + const hasChanged = Object.keys(newState).some( + key => prevState[key as keyof typeof prevState] !== newState[key as keyof typeof newState] + ); + + if (hasChanged) { + console.log('Toolbar state updated:', newState); + return newState; + } + + return prevState; + }); + } catch (error) { + console.error('툴바 상태 업데이트 에러:', error); + } + }, 50); // 50ms 디바운스 + }, [editor]); + + // editor 이벤트 리스너 설정 + useEffect(() => { + if (!editor) return; + + // 초기 상태 설정 + updateToolbarState(); + + // 이벤트 리스너 등록 (selectionUpdate만 사용 - transaction은 너무 자주 발생) + editor.on('selectionUpdate', updateToolbarState); + editor.on('focus', updateToolbarState); + editor.on('blur', updateToolbarState); + + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + editor.off('selectionUpdate', updateToolbarState); + editor.off('focus', updateToolbarState); + editor.off('blur', updateToolbarState); + }; + }, [editor, updateToolbarState]); + + // 명령 실행 헬퍼 함수 + const executeCommand = useCallback((command: () => void) => { + if (editor && !disabled) { + command(); + // 명령 실행 후 즉시 상태 업데이트 + updateToolbarState(); + } + }, [editor, disabled, updateToolbarState]); + + if (!editor) { + return null; + } + + + return ( + <TooltipProvider> + <div className="border border-input bg-transparent rounded-t-md"> + <div className="flex flex-wrap gap-1 p-1"> + {/* 텍스트 스타일 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.bold} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleBold().run() + )} + disabled={disabled} + > + <Bold className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>굵게 (Ctrl+B)</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.italic} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleItalic().run() + )} + disabled={disabled} + > + <Italic className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>기울임 (Ctrl+I)</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.underline} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleUnderline().run() + )} + disabled={disabled} + > + <Underline className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>밑줄</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.strike} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleStrike().run() + )} + disabled={disabled} + > + <Strikethrough className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>취소선</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 제목 및 단락 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.heading} + disabled={disabled} + > + <Type className="h-4 w-4" /> + </Toggle> + </span> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().toggleHeading({ level: 1 }).run() + )} + className="flex items-center" + > + <span className="text-xl font-bold">제목 1</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().toggleHeading({ level: 2 }).run() + )} + className="flex items-center" + > + <span className="text-lg font-bold">제목 2</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().toggleHeading({ level: 3 }).run() + )} + className="flex items-center" + > + <span className="text-base font-bold">제목 3</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().setParagraph().run() + )} + className="flex items-center" + > + <span>본문</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + <Separator orientation="vertical" className="h-6" /> + + {/* 리스트 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.bulletList} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleBulletList().run() + )} + disabled={disabled} + > + <List className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>글머리 기호</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.orderedList} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleOrderedList().run() + )} + disabled={disabled} + > + <ListOrdered className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>번호 매기기</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.blockquote} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleBlockquote().run() + )} + disabled={disabled} + > + <Quote className="h-4 w-4" /> + </Toggle> + + </span> + </TooltipTrigger> + <TooltipContent> + <p>인용문</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 텍스트 정렬 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <span className="inline-flex"> + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={toolbarState.textAlign !== 'left'} + disabled={disabled} + > + {toolbarState.textAlign === 'center' ? ( + <AlignCenter className="h-4 w-4" /> + ) : toolbarState.textAlign === 'right' ? ( + <AlignRight className="h-4 w-4" /> + ) : toolbarState.textAlign === 'justify' ? ( + <AlignJustify className="h-4 w-4" /> + ) : ( + <AlignLeft className="h-4 w-4" /> + )} + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>텍스트 정렬</p> + </TooltipContent> + </Tooltip> + </span> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().setTextAlign('left').run() + )} + className="flex items-center" + > + <AlignLeft className="mr-2 h-4 w-4" /> + <span>왼쪽 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().setTextAlign('center').run() + )} + className="flex items-center" + > + <AlignCenter className="mr-2 h-4 w-4" /> + <span>가운데 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().setTextAlign('right').run() + )} + className="flex items-center" + > + <AlignRight className="mr-2 h-4 w-4" /> + <span>오른쪽 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => executeCommand(() => + editor.chain().focus().setTextAlign('justify').run() + )} + className="flex items-center" + > + <AlignJustify className="mr-2 h-4 w-4" /> + <span>양쪽 정렬</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + <Separator orientation="vertical" className="h-6" /> + + {/* 링크 */} + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={toolbarState.link} + onPressedChange={() => { + if (toolbarState.link) { + executeCommand(() => editor.chain().focus().unsetLink().run()); + } else { + const url = window.prompt('URL을 입력하세요:'); + if (url) { + executeCommand(() => editor.chain().focus().setLink({ href: url }).run()); + } + } + }} + disabled={disabled} + > + <Link className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>링크 {toolbarState.link ? '제거' : '삽입'}</p> + </TooltipContent> + </Tooltip> + + {/* 이미지 업로드 */} + <Tooltip> + <TooltipTrigger asChild> + <div className="relative"> + <input + type="file" + accept="image/*" + className="hidden" + id="image-upload" + onChange={onImageUpload} + /> + <Toggle + size="sm" + pressed={false} + onPressedChange={() => { + document.getElementById('image-upload')?.click(); + }} + disabled={disabled} + > + <Image className="h-4 w-4" /> + </Toggle> + </div> + </TooltipTrigger> + <TooltipContent> + <p>이미지 삽입</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 첨자 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.subscript} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleSubscript().run() + )} + disabled={disabled} + > + <Subscript className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>아래 첨자</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.superscript} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleSuperscript().run() + )} + disabled={disabled} + > + <Superscript className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>위 첨자</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 하이라이트 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.highlight} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleHighlight().run() + )} + disabled={disabled} + > + <Highlighter className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>하이라이트</p> + </TooltipContent> + </Tooltip> + + {/* 체크리스트 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.taskList} + onPressedChange={() => executeCommand(() => + editor.chain().focus().toggleTaskList().run() + )} + disabled={disabled} + > + <CheckSquare className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>체크리스트</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 테이블 */} + <Tooltip> + <TooltipTrigger asChild> + <span + className="inline-flex" /* Trigger용 */ + tabIndex={0} /* 키보드 접근성 */ + > + <Toggle + size="sm" + pressed={toolbarState.table} + onPressedChange={() => { + if (toolbarState.table) { + executeCommand(() => editor.chain().focus().deleteTable().run()); + } else { + executeCommand(() => editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run()); + } + }} + disabled={disabled} + > + <Table className="h-4 w-4" /> + </Toggle> + </span> + </TooltipTrigger> + <TooltipContent> + <p>{toolbarState.table ? '테이블 삭제' : '테이블 삽입'}</p> + </TooltipContent> + </Tooltip> + + <Separator orientation="vertical" className="h-6" /> + + {/* 실행 취소/다시 실행 */} + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={false} + onPressedChange={() => executeCommand(() => + editor.chain().focus().undo().run() + )} + disabled={!editor.can().undo() || disabled} + > + <Undo className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>실행 취소 (Ctrl+Z)</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={false} + onPressedChange={() => executeCommand(() => + editor.chain().focus().redo().run() + )} + disabled={!editor.can().redo() || disabled} + > + <Redo className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>다시 실행 (Ctrl+Y)</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </TooltipProvider> + ) +}
\ No newline at end of file diff --git a/components/qna/tiptap-editor.tsx b/components/qna/tiptap-editor.tsx new file mode 100644 index 00000000..4ab7f097 --- /dev/null +++ b/components/qna/tiptap-editor.tsx @@ -0,0 +1,287 @@ +import { useEditor, EditorContent } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Underline from '@tiptap/extension-underline' +import { Image as TiptapImage } from '@tiptap/extension-image' +import Link from '@tiptap/extension-link' +import TextAlign from '@tiptap/extension-text-align' +import Subscript from '@tiptap/extension-subscript' +import Superscript from '@tiptap/extension-superscript' +import Table from '@tiptap/extension-table' +import TableRow from '@tiptap/extension-table-row' +import TableCell from '@tiptap/extension-table-cell' +import TableHeader from '@tiptap/extension-table-header' +import Highlight from '@tiptap/extension-highlight' +import TaskList from '@tiptap/extension-task-list' +import TaskItem from '@tiptap/extension-task-item' +import BulletList from '@tiptap/extension-bullet-list' +import ListItem from '@tiptap/extension-list-item' +import OrderedList from '@tiptap/extension-ordered-list' +import Blockquote from '@tiptap/extension-blockquote' +import { Toolbar } from './richTextEditor' + +interface TiptapEditorProps { + content: string; + setContent: (content: string) => void; + disabled?: boolean; + height?: string; // 높이 prop 추가 +} + +export default function TiptapEditor({ content, setContent, disabled, height = "300px" }: TiptapEditorProps) { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + blockquote: false, + codeBlock: false, + code: false, + heading: { + levels: [1, 2, 3] + } + }), + Underline, + TiptapImage.configure({ + HTMLAttributes: { + class: 'max-w-full h-auto', + style: 'max-width: 600px; height: auto;', + }, + }), + Link.configure({ + openOnClick: true, + linkOnPaste: true, + }), + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + defaultAlignment: 'left', + }), + Subscript, + Superscript, + Table.configure({ + resizable: true, + HTMLAttributes: { + class: 'border-collapse table-auto w-full', + }, + }), + TableRow.configure({ + HTMLAttributes: { + class: 'border-b border-gray-200', + }, + }), + TableCell.configure({ + HTMLAttributes: { + class: 'border border-gray-200 p-2', + }, + }), + TableHeader.configure({ + HTMLAttributes: { + class: 'border border-gray-200 p-2 bg-gray-100 font-bold', + }, + }), + Highlight.configure({ + multicolor: true, + }), + TaskList, + TaskItem.configure({ + nested: true, + }), + BulletList.configure({ + HTMLAttributes: { + class: 'list-disc ml-4', + }, + }), + ListItem, + OrderedList.configure({ + HTMLAttributes: { + class: 'list-decimal ml-4', + }, + }), + Blockquote.configure({ + HTMLAttributes: { + class: 'border-l-4 border-gray-300 pl-4 my-4 italic bg-gray-50 py-2', + }, + }), + ], + content: content, + editable: !disabled, + editorProps: { + attributes: { + class: 'w-full bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 prose prose-sm max-w-none', + }, + handleDrop: (view, event, slice, moved) => { + if (!moved && event.dataTransfer?.files.length) { + const file = event.dataTransfer.files[0]; + if (file.type.startsWith('image/')) { + handleImageUpload(file); + return true; + } + } + return false; + }, + handlePaste: (view, event) => { + if (event.clipboardData?.files.length) { + const file = event.clipboardData.files[0]; + if (file.type.startsWith('image/')) { + handleImageUpload(file); + return true; + } + } + return false; + }, + }, + onUpdate: ({ editor }) => { + setContent(editor.getHTML()); + }, + }) + + // 이미지 크기 확인 함수 + const getImageDimensions = (src: string): Promise<{ width: number; height: number }> => + new Promise((resolve, reject) => { + // 1. 올바른 생성자 + const img = new Image(); + + // 2. 로딩 완료 시 naturalWidth/Height 사용 + img.onload = () => { + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + }; + + img.onerror = () => reject(new Error('이미지 로드 실패')); + img.src = src; // 3. 마지막에 src 지정 + }); + + + async function uploadImageToServer(file: File): Promise<string> { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/richtext', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const { error } = await res.json(); + throw new Error(error ?? 'upload failed'); + } + + const { url } = (await res.json()) as { url: string }; + return url; + } + +// Base64 → 서버 업로드 방식으로 교체 +const handleImageUpload = async (file: File) => { + try { + if (file.size > 3 * 1024 * 1024) { + alert('이미지 크기는 3 MB 이하만 지원됩니다.'); + return; + } + if (!file.type.startsWith('image/')) { + alert('이미지 파일만 업로드 가능합니다.'); + return; + } + + const url = await uploadImageToServer(file); // ← 업로드 & URL 획득 + + // 이미지 크기(너비)에 따라 style 조정 + const { width, height } = await getImageDimensions(url); + const maxWidth = 600; + const newW = width > maxWidth ? maxWidth : width; + const newH = width > maxWidth ? (height * maxWidth) / width : height; + + editor + ?.chain() + .focus() + .setImage({ + src: url, + style: `width:${newW}px;height:${newH}px;max-width:100%;`, + }) + .run(); + } catch (e) { + console.error(e); + alert('이미지 업로드에 실패했습니다.'); + } +}; + + // 높이 계산 (100%인 경우 flex 사용, 아니면 구체적 높이) + const containerStyle = height === "100%" + ? { height: "100%" } + : { height }; + + const editorContentStyle = height === "100%" + ? { flex: 1, minHeight: 0 } + : { height: `calc(${height} - 60px)` }; // 툴바 높이(60px) 제외 + + return ( + <div + className={`border rounded-md bg-background ${height === "100%" ? "flex flex-col h-full" : ""}`} + style={containerStyle} + > + {/* 툴바: 항상 고정 위치 */} + <div className="flex-shrink-0 border-b"> + <Toolbar + editor={editor} + disabled={disabled} + onImageUpload={(e) => { + const file = e.target.files?.[0]; + if (file) { + handleImageUpload(file); + } + }} + /> + </div> + + {/* 에디터 내용: 스크롤 가능 영역 */} + <div + className="overflow-y-auto" + style={editorContentStyle} + > + <EditorContent + editor={editor} + className="h-full" + /> + </div> + + {/* 전역 이미지 스타일 */} + <style jsx>{` + .ProseMirror { + min-height: 150px; + padding: 12px; + outline: none; + } + + .ProseMirror img { + max-width: 600px !important; + height: auto !important; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + cursor: pointer; + } + + .ProseMirror img:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transition: box-shadow 0.2s ease; + } + + /* 스크롤바 스타일링 */ + .overflow-y-auto::-webkit-scrollbar { + width: 6px; + } + + .overflow-y-auto::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + .overflow-y-auto::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + } + + .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; + } + `}</style> + </div> + ) +}
\ No newline at end of file diff --git a/components/tech-vendors/tech-vendor-container.tsx b/components/tech-vendors/tech-vendor-container.tsx index 583d507c..505a2ea4 100644 --- a/components/tech-vendors/tech-vendor-container.tsx +++ b/components/tech-vendors/tech-vendor-container.tsx @@ -11,7 +11,7 @@ import { DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
-
+import { InformationButton } from "@/components/information/information-button"
interface VendorType {
id: string
name: string
@@ -61,7 +61,10 @@ export function TechVendorContainer({ <div className="flex items-center justify-between">
{/* 왼쪽: 타이틀 & 설명 */}
<div>
- <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 리스트</h2>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 리스트</h2>
+ <InformationButton pagePath="evcp/tech-vendors" />
+ </div>
<p className="text-muted-foreground">
기술영업 벤더에 대한 요약 정보를 확인하고 관리할 수 있습니다.
</p>
|
