summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-02 00:45:49 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-02 00:45:49 +0000
commit2acf5f8966a40c1c9a97680c8dc263ee3f1ad3d1 (patch)
treef406b5c86f563347c7fd088a85fd1a82284dc5ff /components
parent6a9ca20deddcdcbe8495cf5a73ec7ea5f53f9b55 (diff)
(대표님/최겸) 20250702 변경사항 업데이트
Diffstat (limited to 'components')
-rw-r--r--components/error-boundary.tsx45
-rw-r--r--components/layout/SessionManager.tsx2
-rw-r--r--components/qna/comment-section.tsx166
-rw-r--r--components/qna/richTextEditor.tsx676
-rw-r--r--components/qna/tiptap-editor.tsx287
-rw-r--r--components/tech-vendors/tech-vendor-container.tsx7
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>