신규 페이지: - 회계관리: 거래처, 예상비용, 청구서, 발주서 - 게시판: 공지사항, 자료실, 커뮤니티 - 고객센터: 문의/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>
287 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
} |