Files
sam-react-prod/src/components/board/RichTextEditor/MenuBar.tsx
byeongcheolryu c6b605200d feat: 신규 페이지 구현 및 HR/설정 기능 개선
신규 페이지:
- 회계관리: 거래처, 예상비용, 청구서, 발주서
- 게시판: 공지사항, 자료실, 커뮤니티
- 고객센터: 문의/FAQ
- 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역
- 리포트 (차트 시각화)
- 개발자 테스트 URL 페이지

기능 개선:
- HR 직원관리/휴가관리/카드관리 강화
- IntegratedListTemplateV2 확장
- AuthenticatedLayout 패딩 표준화
- 로그인 페이지 UI 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 19:12:34 +09:00

287 lines
8.1 KiB
TypeScript

'use client';
/**
* TipTap 에디터 툴바
*/
import { useCallback, useRef } from 'react';
import { Editor } from '@tiptap/react';
import { Button } from '@/components/ui/button';
import {
Bold,
Italic,
Underline,
Strikethrough,
AlignLeft,
AlignCenter,
AlignRight,
List,
ListOrdered,
Link as LinkIcon,
Image as ImageIcon,
Undo,
Redo,
} from 'lucide-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { useState } from 'react';
interface MenuBarProps {
editor: Editor | null;
onImageUpload?: (file: File) => Promise<string>;
}
export function MenuBar({ editor, onImageUpload }: MenuBarProps) {
const [linkUrl, setLinkUrl] = useState('');
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 링크 추가 (hooks는 조건문 전에 선언해야 함)
const handleAddLink = useCallback(() => {
if (!editor) return;
if (linkUrl) {
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: linkUrl })
.run();
setLinkUrl('');
setIsLinkPopoverOpen(false);
}
}, [editor, linkUrl]);
// 링크 제거
const handleRemoveLink = useCallback(() => {
if (!editor) return;
editor.chain().focus().unsetLink().run();
setIsLinkPopoverOpen(false);
}, [editor]);
// 이미지 업로드
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!editor) return;
const file = e.target.files?.[0];
if (!file) return;
if (onImageUpload) {
try {
const url = await onImageUpload(file);
editor.chain().focus().setImage({ src: url }).run();
} catch (error) {
console.error('Image upload failed:', error);
}
} else {
// 기본: Base64로 삽입 (개발용)
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
editor.chain().focus().setImage({ src: base64 }).run();
};
reader.readAsDataURL(file);
}
// 파일 input 초기화
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [editor, onImageUpload]);
// editor가 없으면 렌더링 안 함 (hooks 이후에 체크)
if (!editor) {
return null;
}
const buttonClass = 'h-8 w-8 p-0';
const activeClass = 'bg-gray-200';
return (
<div className="flex flex-wrap items-center gap-1 border-b border-gray-200 p-2 bg-gray-50 rounded-t-md">
{/* Undo/Redo */}
<Button
type="button"
variant="ghost"
size="sm"
className={buttonClass}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="실행 취소"
>
<Undo className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={buttonClass}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="다시 실행"
>
<Redo className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-gray-300 mx-1" />
{/* 텍스트 스타일 */}
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive('bold') ? activeClass : ''}`}
onClick={() => editor.chain().focus().toggleBold().run()}
title="굵게 (Ctrl+B)"
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive('italic') ? activeClass : ''}`}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="기울임 (Ctrl+I)"
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive('underline') ? activeClass : ''}`}
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="밑줄 (Ctrl+U)"
>
<Underline className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive('strike') ? activeClass : ''}`}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="취소선"
>
<Strikethrough className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-gray-300 mx-1" />
{/* 정렬 */}
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive({ textAlign: 'left' }) ? activeClass : ''}`}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
title="왼쪽 정렬"
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive({ textAlign: 'center' }) ? activeClass : ''}`}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
title="가운데 정렬"
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive({ textAlign: 'right' }) ? activeClass : ''}`}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
title="오른쪽 정렬"
>
<AlignRight className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-gray-300 mx-1" />
{/* 목록 */}
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive('bulletList') ? activeClass : ''}`}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="글머리 기호"
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive('orderedList') ? activeClass : ''}`}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="번호 매기기"
>
<ListOrdered className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-gray-300 mx-1" />
{/* 링크 */}
<Popover open={isLinkPopoverOpen} onOpenChange={setIsLinkPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={`${buttonClass} ${editor.isActive('link') ? activeClass : ''}`}
title="링크"
>
<LinkIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3">
<div className="space-y-3">
<Input
placeholder="URL을 입력하세요"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddLink()}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAddLink} className="flex-1">
</Button>
{editor.isActive('link') && (
<Button size="sm" variant="outline" onClick={handleRemoveLink}>
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
{/* 이미지 */}
<Button
type="button"
variant="ghost"
size="sm"
className={buttonClass}
onClick={() => fileInputRef.current?.click()}
title="이미지"
>
<ImageIcon className="h-4 w-4" />
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
/>
</div>
);
}