KOOHAWN/AI_PDF_TOOLKIt
0
1import React, { useState, useRef } from 'react';2import { PDFDocument } from 'pdf-lib';3import { motion, AnimatePresence } from 'motion/react';4import JSZip from 'jszip';5import {6 FilePlus2,7 Trash2,8 Download,9 AlertCircle,10 X,11 CheckCircle2,12 FolderOpen,13 ChevronRight,14 Home,15 ArrowRight,16 Files,17 FileOutput,18 Printer,19 Search,20 FileImage,21 Film,22 Images,23 Ruler,24 Layers3,25 Scissors,26 Braces,27 Plus,28 Settings,29 MoreHorizontal,30 ArrowUp,31 ArrowDown,32} from 'lucide-react';33import { cn } from './lib/utils';34import { moveSelectedItemsAroundTarget, moveSelectedItemsToPosition } from './lib/listOrder';35import { CompareTab } from './features/compare/CompareTab';36import { IllustratorViewerTab } from './features/illustrator/IllustratorViewerTab';37import { ImageToolkitTab } from './features/images/ImageToolkitTab';38import type { ImageToolMode } from './features/images/types';39import { GifStudioTab } from './features/gif-studio/GifStudioTab';40import { getLayoutMode } from './app/layoutMode';41import type { AppTab } from './app/navigation';42import { AppShell } from './ui/shell/AppShell';43import { PageHeader } from './ui/layout/PageHeader';44import { Alert } from './ui/primitives/Alert';45import { Button } from './ui/primitives/Button';46import { Field, fieldControlClassName } from './ui/primitives/Field';47import { Panel, PanelContent, PanelFooter, PanelHeader } from './ui/primitives/Panel';48import { FileWorkflowGate } from './ui/workflow/FileWorkflowGate';49import { NewWorkButton } from './ui/workflow/NewWorkButton';50import { EditableOrderNumber } from './ui/workflow/EditableOrderNumber';51import { GANI_FLOW_MARK_URL, GaniFlowLogo } from './ui/brand/GaniFlowLogo';52 53interface FileItem {54 id: string;55 file: File;56 name: string;57 size: number;58}59 60type DropInsertPosition = 'before' | 'after';61 62type DownloadItem = {63 id: string;64 label: string;65 fileName: string;66 blob: Blob;67 note?: string;68 emphasis?: boolean;69 editableName?: string;70 pageNumber?: number;71 totalPages?: number;72};73 74type FeatureCard = {75 tab: Exclude<AppTab, 'home'>;76 title: string;77 eyebrow: string;78 description: string;79 formats: string;80 purpose: string;81 cta: string;82 accent: string;83 icon: React.ComponentType<{ className?: string }>;84};85 86const featureCards: FeatureCard[] = [87 {88 tab: 'merge',89 title: 'PDF 병합',90 eyebrow: '문서 정리',91 description: 'PDF, PDF 호환 AI, 이미지, SVG 파일을 순서대로 하나의 PDF로 합칩니다.',92 formats: 'PDF, AI, 이미지, SVG',93 purpose: '인쇄물 묶음, 이미지 포함 납품 파일 정리',94 cta: '파일 합치기',95 accent: 'from-sky-500 to-cyan-400',96 icon: Files,97 },98 {99 tab: 'split',100 title: 'PDF 분리',101 eyebrow: '문서 정리',102 description: '여러 페이지 PDF를 페이지별 단일 PDF로 나누고 ZIP으로 내려받습니다.',103 formats: 'PDF',104 purpose: '대지별 파일 분리, 페이지별 납품',105 cta: '페이지 나누기',106 accent: 'from-blue-500 to-indigo-400',107 icon: FileOutput,108 },109 {110 tab: 'outline',111 title: '인쇄용 변환',112 eyebrow: '출력 준비',113 description: '원본과 인쇄용 파일을 함께 내려받을 수 있도록 아웃라인 변환을 실행합니다.',114 formats: 'PDF, AI',115 purpose: '폰트 깨짐 방지, 출력소 전달',116 cta: '인쇄용 만들기',117 accent: 'from-amber-500 to-orange-400',118 icon: Printer,119 },120 {121 tab: 'compare',122 title: 'PDF 비교',123 eyebrow: '정밀 검수',124 description: '원본과 수정본을 대조해 텍스트, 수치, 도면 변경 후보를 검수합니다.',125 formats: 'PDF',126 purpose: '수정 전후 확인, 인쇄 사고 예방',127 cta: '변경점 찾기',128 accent: 'from-rose-500 to-red-400',129 icon: Search,130 },131 {132 tab: 'illustrator',133 title: '뷰어',134 eyebrow: '벡터 확인',135 description: 'AI, EPS, SVG, PDF, PSD, PSB 파일을 빠르게 열어 확대 검수하고 원본을 내려받습니다.',136 formats: 'AI, EPS, SVG, PDF, PSD, PSB',137 purpose: '파일 미리보기, 확대 검수',138 cta: '파일 열어보기',139 accent: 'from-emerald-500 to-teal-400',140 icon: FileImage,141 },142 {143 tab: 'images',144 title: '이미지 툴킷',145 eyebrow: '상세페이지',146 description: '이미지 크기 변경, 이어붙이기, 자르기, HTML 이미지 수집을 처리합니다.',147 formats: 'PNG, JPG, JPEG, WebP, HTML',148 purpose: '상세페이지 제작, 쇼핑몰 이미지 정리',149 cta: '이미지 작업하기',150 accent: 'from-violet-500 to-fuchsia-400',151 icon: Images,152 },153];154 155const homeRailItems: Array<{ tab?: AppTab; label: string; icon: React.ComponentType<{ className?: string }>; muted?: boolean; beta?: boolean }> = [156 { label: 'New', icon: Plus, muted: true },157 { tab: 'home', label: '홈', icon: Home },158 { tab: 'merge', label: '병합', icon: Files },159 { tab: 'split', label: '분리', icon: FileOutput },160 { tab: 'outline', label: '출력', icon: Printer },161 { tab: 'compare', label: '비교', icon: Search },162 { tab: 'illustrator', label: '뷰어', icon: FileImage },163 { tab: 'images', label: '이미지', icon: Images },164 { tab: 'gif', label: 'GIF', icon: Film, beta: true },165];166 167type WorkspaceItem = {168 tab: Exclude<AppTab, 'home'>;169 title: string;170 desc: string;171 icon: React.ComponentType<{ className?: string }>;172 color: string;173 imageMode?: ImageToolMode;174 beta?: boolean;175};176 177const workspaceGroups: Array<{178 label: string;179 items: WorkspaceItem[];180}> = [181 {182 label: '문서 준비',183 items: [184 { tab: 'merge', title: 'PDF 병합', desc: 'PDF, AI, 이미지 묶음', icon: Files, color: 'text-sky-500' },185 { tab: 'split', title: 'PDF 분리', desc: '페이지별 PDF 저장', icon: FileOutput, color: 'text-blue-500' },186 { tab: 'outline', title: '인쇄용 변환', desc: '출력 전달 파일 생성', icon: Printer, color: 'text-amber-500' },187 ],188 },189 {190 label: '정밀 검수',191 items: [192 { tab: 'compare', title: 'PDF 비교', desc: '수정 전후 변경점 확인', icon: Search, color: 'text-rose-500' },193 ],194 },195 {196 label: '파일 확인',197 items: [198 { tab: 'illustrator', title: '뷰어', desc: 'AI, PDF, PSD/PSB 확대 검수', icon: FileImage, color: 'text-emerald-500' },199 ],200 },201 {202 label: '이미지 제작',203 items: [204 { tab: 'gif', title: 'GIF 생성', desc: '모션 편집기 준비 중', icon: Film, color: 'text-pink-500', beta: true },205 { tab: 'images', imageMode: 'resize', title: '크기 변경', desc: '지정 폭 일괄 변환', icon: Ruler, color: 'text-violet-500' },206 { tab: 'images', imageMode: 'stitch', title: '이어붙이기', desc: '이미지 세로 합치기', icon: Layers3, color: 'text-fuchsia-500' },207 { tab: 'images', imageMode: 'split', title: '자르기', desc: '긴 이미지 분할', icon: Scissors, color: 'text-orange-500' },208 { tab: 'images', imageMode: 'html', title: 'HTML 수집', desc: '이미지 링크 추출', icon: Braces, color: 'text-cyan-500' },209 ],210 },211];212 213function formatSize(bytes: number) {214 if (bytes === 0) return '0 B';215 const k = 1024;216 const sizes = ['B', 'KB', 'MB', 'GB'];217 const i = Math.floor(Math.log(bytes) / Math.log(k));218 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];219}220 221function uniqueWorkspaceItems(items: WorkspaceItem[]) {222 const seen = new Set<string>();223 return items.filter(item => {224 const key = workspaceItemKey(item);225 if (seen.has(key)) return false;226 seen.add(key);227 return true;228 });229}230 231function workspaceItemKey(item: WorkspaceItem) {232 return item.imageMode ? `${item.tab}-${item.imageMode}` : `${item.tab}-${item.title}`;233}234 235function workspacePreviewText(item: WorkspaceItem) {236 if (item.tab === 'compare') {237 return {238 title: '수정 전후 변경점 확인',239 desc: '두 PDF를 나란히 놓고 숫자, 문구, 도면 차이를 카드와 표시 영역으로 검수합니다.',240 meta: '샘플 비교 결과',241 };242 }243 if (item.tab === 'merge') {244 return {245 title: '인쇄 파일 한 번에 묶기',246 desc: 'PDF, AI, 이미지 파일을 순서대로 정리해 하나의 전달용 PDF로 만듭니다.',247 meta: '샘플 파일 목록',248 };249 }250 if (item.tab === 'split') {251 return {252 title: '페이지별 PDF 자동 분리',253 desc: '여러 페이지 PDF를 읽어 각 페이지를 독립 PDF로 만들고 ZIP 패키지로 정리합니다.',254 meta: '페이지별 결과',255 };256 }257 if (item.tab === 'outline') {258 return {259 title: '출력용 변환 파일 생성',260 desc: '인쇄 전달에 필요한 변환 작업을 실행하고 결과 파일을 바로 내려받습니다.',261 meta: '샘플 변환 결과',262 };263 }264 if (item.tab === 'illustrator') {265 return {266 title: 'AI, PDF, PSD/PSB 확대 검수',267 desc: '대지와 레이어를 크게 열어보고 원본 파일을 시각적으로 확인합니다.',268 meta: 'AI · PS · PDF',269 };270 }271 if (item.imageMode === 'resize') {272 return {273 title: '이미지 일괄 크기 변경',274 desc: '여러 이미지를 지정 폭 기준으로 맞추고 PNG 결과를 생성합니다.',275 meta: '샘플 결과',276 };277 }278 if (item.imageMode === 'stitch') {279 return {280 title: '이미지 세로 이어붙이기',281 desc: '상세페이지 이미지를 순서대로 쌓아 긴 이미지로 합칩니다.',282 meta: '샘플 결과',283 };284 }285 if (item.imageMode === 'split') {286 return {287 title: '긴 이미지 자르기',288 desc: '긴 상세 이미지를 원하는 기준으로 나눠 업로드용 파일로 정리합니다.',289 meta: '샘플 분할 결과',290 };291 }292 return {293 title: 'HTML 이미지 링크 수집',294 desc: '상세페이지 HTML에서 이미지 링크를 추출해 제작 자료를 정리합니다.',295 meta: '샘플 링크 목록',296 };297}298 299function workspacePreviewImagePath(item: WorkspaceItem) {300 if (item.tab === 'merge') return '/workspace-previews/merge.png';301 if (item.tab === 'split') return '/workspace-previews/merge.png';302 if (item.tab === 'outline') return '/workspace-previews/outline.png';303 if (item.tab === 'compare') return '/workspace-previews/compare.png';304 if (item.tab === 'illustrator') return '/workspace-previews/viewer.png';305 if (item.imageMode === 'resize') return '/workspace-previews/image-resize.png';306 if (item.imageMode === 'stitch') return '/workspace-previews/image-stitch.png';307 if (item.imageMode === 'split') return '/workspace-previews/image-split.png';308 return '/workspace-previews/image-html.png';309}310 311function WorkspacePreviewMeta({ item, meta }: { item: WorkspaceItem; meta: string }) {312 if (item.tab !== 'illustrator') {313 return <span className="rounded-full bg-slate-100 px-2 py-1 text-[10px] font-bold text-slate-500">{meta}</span>;314 }315 316 const fileTypes = [317 { label: 'AI', className: 'bg-[#2b1600] text-[#ff9f1a]' },318 { label: 'PS', className: 'bg-[#061a33] text-sky-300' },319 { label: 'PDF', className: 'bg-rose-50 text-rose-600 ring-rose-100' },320 ];321 322 return (323 <span className="flex items-center gap-1" aria-label={meta}>324 {fileTypes.map(type => (325 <span326 key={type.label}327 className={cn(328 'flex h-[23px] w-[23px] items-center justify-center rounded-md text-[10px] font-black leading-none shadow-sm ring-1 ring-slate-200',329 type.className330 )}331 >332 {type.label}333 </span>334 ))}335 </span>336 );337}338 339function WorkspaceHoverPreview({ item }: { item: WorkspaceItem }) {340 const Icon = item.icon;341 const preview = workspacePreviewText(item);342 const previewImage = workspacePreviewImagePath(item);343 344 return (345 <div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-5 hidden w-[440px] max-w-[calc(100vw-2rem)] -translate-x-1/2 rounded-2xl border border-slate-200 bg-white p-3 text-left shadow-[0_24px_70px_rgba(15,23,42,0.18)] ring-1 ring-slate-950/5 lg:block">346 <div className="overflow-hidden rounded-xl border border-slate-200 bg-slate-50">347 <div className="flex items-center justify-between border-b border-slate-200 bg-white px-3 py-2">348 <div className="flex items-center gap-2">349 <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-slate-950">350 <Icon className={cn('h-4 w-4', item.color)} />351 </span>352 <span className="text-[11px] font-black text-slate-900">{item.title}</span>353 </div>354 <WorkspacePreviewMeta item={item} meta={preview.meta} />355 </div>356 <div className="relative aspect-[16/9] overflow-hidden bg-slate-100">357 <img358 src={previewImage}359 alt={`${preview.title} 화면 미리보기`}360 className="h-full w-full object-contain object-top"361 draggable={false}362 />363 <div className="pointer-events-none absolute inset-0 ring-1 ring-inset ring-slate-950/5" />364 </div>365 </div>366 <div className="mt-3 px-1">367 <p className="text-sm font-black leading-5 text-slate-950">{preview.title}</p>368 <p className="mt-1 text-xs font-semibold leading-5 text-slate-500">{preview.desc}</p>369 </div>370 </div>371 );372}373 374const MERGE_EXTENSIONS = ['pdf', 'ai', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'];375const IMAGE_MERGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'];376const DEFAULT_SPLIT_NAME_RULE = '{name}_page_{page0}';377 378function extensionOfName(name: string) {379 return name.split('.').pop()?.toLowerCase() || '';380}381 382function baseNameOfFile(name: string) {383 const dot = name.lastIndexOf('.');384 return dot > 0 ? name.slice(0, dot) : name;385}386 387function safeOutputBaseName(name: string, fallback: string) {388 const trimmed = name.trim();389 return (trimmed || fallback).replace(/[\\/:*?"<>|]/g, '_');390}391 392function splitEditablePdfName(item: DownloadItem) {393 return safeOutputBaseName(item.editableName ?? baseNameOfFile(item.fileName), baseNameOfFile(item.fileName));394}395 396function splitDownloadFileName(item: DownloadItem) {397 return `${splitEditablePdfName(item)}.pdf`;398}399 400function uniqueFileName(fileName: string, used: Map<string, number>) {401 const ext = extensionOfName(fileName);402 const base = ext ? fileName.slice(0, -(ext.length + 1)) : fileName;403 let candidate = fileName;404 let suffix = 2;405 406 while (used.has(candidate.toLowerCase())) {407 candidate = ext ? `${base}_${suffix}.${ext}` : `${fileName}_${suffix}`;408 suffix += 1;409 }410 411 used.set(candidate.toLowerCase(), 1);412 return candidate;413}414 415function padPageNumber(pageNumber: number, totalPages: number) {416 return String(pageNumber).padStart(Math.max(2, String(totalPages).length), '0');417}418 419function formatSplitRuleName(rule: string, baseName: string, pageNumber: number, totalPages: number) {420 const safeBaseName = safeOutputBaseName(baseName, '분리_문서');421 const page = String(pageNumber);422 const page0 = padPageNumber(pageNumber, totalPages);423 const total = String(totalPages);424 const rendered = (rule.trim() || DEFAULT_SPLIT_NAME_RULE)425 .replace(/\{name\}/g, safeBaseName)426 .replace(/\{page0\}/g, page0)427 .replace(/\{page\}/g, page)428 .replace(/\{total\}/g, total);429 430 return safeOutputBaseName(rendered, `${safeBaseName}_page_${page0}`);431}432 433function downloadBlob(blob: Blob, filename: string) {434 const url = URL.createObjectURL(blob);435 const a = document.createElement('a');436 a.href = url;437 a.download = filename;438 document.body.appendChild(a);439 a.click();440 document.body.removeChild(a);441 URL.revokeObjectURL(url);442}443 444async function readImage(file: File) {445 const url = URL.createObjectURL(file);446 try {447 const img = new Image();448 img.decoding = 'async';449 await new Promise<void>((resolve, reject) => {450 img.onload = () => resolve();451 img.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));452 img.src = url;453 });454 return img;455 } finally {456 URL.revokeObjectURL(url);457 }458}459 460async function addImagePageToPdf(target: PDFDocument, file: File) {461 const img = await readImage(file);462 const width = img.naturalWidth || img.width || 1000;463 const height = img.naturalHeight || img.height || 1000;464 const canvas = document.createElement('canvas');465 canvas.width = width;466 canvas.height = height;467 const ctx = canvas.getContext('2d');468 if (!ctx) throw new Error('이미지 변환 캔버스를 만들 수 없습니다.');469 470 ctx.fillStyle = '#ffffff';471 ctx.fillRect(0, 0, width, height);472 ctx.drawImage(img, 0, 0, width, height);473 474 const pngBytes = await new Promise<Uint8Array>((resolve, reject) => {475 canvas.toBlob(async blob => {476 if (!blob) {477 reject(new Error('이미지를 PDF 페이지로 변환하지 못했습니다.'));478 return;479 }480 resolve(new Uint8Array(await blob.arrayBuffer()));481 }, 'image/png');482 });483 484 const embedded = await target.embedPng(pngBytes);485 const page = target.addPage([width, height]);486 page.drawImage(embedded, { x: 0, y: 0, width, height });487}488 489export default function App() {490 const [activeTab, setActiveTab] = useState<AppTab>('home');491 const [imageInitialMode, setImageInitialMode] = useState<ImageToolMode>('resize');492 const [hoveredWorkspaceKey, setHoveredWorkspaceKey] = useState<string | null>(null);493 494 // ── Merge tab state ──495 const [mergeFiles, setMergeFiles] = useState<FileItem[]>([]);496 const [mergeDraggingIds, setMergeDraggingIds] = useState<string[]>([]);497 const [mergeDropTarget, setMergeDropTarget] = useState<{ id: string; position: DropInsertPosition } | null>(null);498 const [mergeSelectedIds, setMergeSelectedIds] = useState<Set<string>>(() => new Set());499 const [mergeSelectionAnchorId, setMergeSelectionAnchorId] = useState<string | null>(null);500 const [isMerging, setIsMerging] = useState(false);501 const [mergeOutputName, setMergeOutputName] = useState('');502 const [mergeError, setMergeError] = useState<string | null>(null);503 const mergeInputRef = useRef<HTMLInputElement>(null);504 505 // ── Split tab state ──506 const [splitFile, setSplitFile] = useState<File | null>(null);507 const [isSplitting, setIsSplitting] = useState(false);508 const [splitOutputName, setSplitOutputName] = useState('');509 const [splitError, setSplitError] = useState<string | null>(null);510 const [splitSuccess, setSplitSuccess] = useState<string | null>(null);511 const [splitDownloads, setSplitDownloads] = useState<DownloadItem[]>([]);512 const [splitNameRule, setSplitNameRule] = useState(DEFAULT_SPLIT_NAME_RULE);513 514 // ── Outline tab state ──515 const [outlineFile, setOutlineFile] = useState<File | null>(null);516 const [outlineName, setOutlineName] = useState('');517 const [isOutlining, setIsOutlining] = useState(false);518 const [outlineError, setOutlineError] = useState<string | null>(null);519 const [outlineSuccess, setOutlineSuccess] = useState<string | null>(null);520 const [outlineDownloads, setOutlineDownloads] = useState<DownloadItem[]>([]);521 const [outlineResultName, setOutlineResultName] = useState('');522 523 // ── Compare tab state ──524 const [compareResults, setCompareResults] = useState<any[] | null>(null);525 const [compareExpanded, setCompareExpanded] = useState(false);526 527 // ── Merge handlers ──528 const addMergeFiles = (uploaded: FileList | File[] | null) => {529 if (!uploaded) return;530 const valid: FileItem[] = [];531 for (const f of Array.from(uploaded)) {532 const ext = extensionOfName(f.name);533 if (MERGE_EXTENSIONS.includes(ext)) {534 valid.push({ id: crypto.randomUUID(), file: f, name: f.name, size: f.size });535 }536 }537 if (valid.length === 0) {538 setMergeError('PDF, AI, 이미지, SVG 파일만 추가할 수 있습니다.');539 return;540 }541 setMergeFiles(prev => [...prev, ...valid]);542 setMergeError(null);543 };544 545 const resetMergeWork = () => {546 setMergeFiles([]);547 setMergeOutputName('');548 setMergeError(null);549 setMergeDraggingIds([]);550 setMergeDropTarget(null);551 setMergeSelectedIds(new Set());552 setMergeSelectionAnchorId(null);553 if (mergeInputRef.current) mergeInputRef.current.value = '';554 };555 556 const removeMergeFile = (id: string) => {557 setMergeFiles(current => current.filter(item => item.id !== id));558 setMergeSelectedIds(current => {559 const next = new Set(current);560 next.delete(id);561 return next;562 });563 if (mergeSelectionAnchorId === id) setMergeSelectionAnchorId(null);564 };565 566 const getDropInsertPosition = (event: React.DragEvent<HTMLElement>): DropInsertPosition => {567 const rect = event.currentTarget.getBoundingClientRect();568 return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';569 };570 571 const toggleMergeFileSelection = (id: string, selectRange = false) => {572 if (selectRange && mergeSelectionAnchorId) {573 const anchorIndex = mergeFiles.findIndex(item => item.id === mergeSelectionAnchorId);574 const targetIndex = mergeFiles.findIndex(item => item.id === id);575 if (anchorIndex >= 0 && targetIndex >= 0) {576 const [start, end] = anchorIndex < targetIndex577 ? [anchorIndex, targetIndex]578 : [targetIndex, anchorIndex];579 setMergeSelectedIds(current => {580 const next = new Set(current);581 mergeFiles.slice(start, end + 1).forEach(item => next.add(item.id));582 return next;583 });584 return;585 }586 }587 588 setMergeSelectedIds(current => {589 const next = new Set(current);590 if (next.has(id)) next.delete(id);591 else next.add(id);592 return next;593 });594 setMergeSelectionAnchorId(id);595 };596 597 const reorderMergeFiles = (movingIds: string[], targetId: string, position: DropInsertPosition = 'before') => {598 if (movingIds.length === 0 || movingIds.includes(targetId)) return;599 setMergeFiles(current => moveSelectedItemsAroundTarget(current, movingIds, targetId, position, (item: FileItem) => item.id));600 };601 602 const runMerge = async () => {603 if (mergeFiles.length < 2) {604 setMergeError('파일을 2개 이상 추가해주세요.');605 return;606 }607 setIsMerging(true);608 setMergeError(null);609 try {610 const merged = await PDFDocument.create();611 for (const item of mergeFiles) {612 const ext = extensionOfName(item.name);613 if (IMAGE_MERGE_EXTENSIONS.includes(ext)) {614 await addImagePageToPdf(merged, item.file);615 continue;616 }617 const bytes = await item.file.arrayBuffer();618 const doc = await PDFDocument.load(bytes, { ignoreEncryption: true });619 const pages = await merged.copyPages(doc, doc.getPageIndices());620 pages.forEach(p => merged.addPage(p));621 }622 const outBytes = await merged.save();623 const name = `${mergeOutputName.trim() || '병합_문서'}.pdf`;624 if ('showSaveFilePicker' in window) {625 try {626 const handle = await (window as any).showSaveFilePicker({627 suggestedName: name,628 types: [{ description: 'PDF 문서', accept: { 'application/pdf': ['.pdf'] } }],629 });630 const writable = await handle.createWritable();631 await writable.write(outBytes);632 await writable.close();633 setIsMerging(false);634 return;635 } catch (err: any) {636 if (err.name === 'AbortError') { setIsMerging(false); return; }637 }638 }639 const blob = new Blob([outBytes], { type: 'application/pdf' });640 const url = URL.createObjectURL(blob);641 const a = document.createElement('a');642 a.href = url; a.download = name;643 document.body.appendChild(a); a.click();644 document.body.removeChild(a);645 URL.revokeObjectURL(url);646 } catch (err: any) {647 setMergeError(err.message || '병합 중 오류가 발생했습니다.');648 } finally {649 setIsMerging(false);650 }651 };652 653 // ── Split handlers ──654 const setSplitTarget = (f: File) => {655 const ext = extensionOfName(f.name);656 if (ext !== 'pdf') {657 setSplitError('PDF 파일만 분리할 수 있습니다.');658 return;659 }660 setSplitFile(f);661 setSplitOutputName(baseNameOfFile(f.name));662 setSplitError(null);663 setSplitSuccess(null);664 setSplitDownloads([]);665 setSplitNameRule(DEFAULT_SPLIT_NAME_RULE);666 };667 668 const resetSplitWork = () => {669 setSplitFile(null);670 setSplitOutputName('');671 setSplitSuccess(null);672 setSplitError(null);673 setSplitDownloads([]);674 setSplitNameRule(DEFAULT_SPLIT_NAME_RULE);675 };676 677 const moveMergeFilesToPosition = (id: string, position: number) => {678 const movingIds = mergeSelectedIds.has(id) ? mergeSelectedIds : new Set([id]);679 setMergeFiles(current => moveSelectedItemsToPosition(current, movingIds, position, (item: FileItem) => item.id));680 };681 682 const runSplit = async () => {683 if (!splitFile) {684 setSplitError('분리할 PDF 파일을 선택해주세요.');685 return;686 }687 setIsSplitting(true);688 setSplitError(null);689 setSplitSuccess(null);690 setSplitDownloads([]);691 692 try {693 const sourceBytes = await splitFile.arrayBuffer();694 const source = await PDFDocument.load(sourceBytes, { ignoreEncryption: true });695 const pageCount = source.getPageCount();696 if (pageCount < 1) throw new Error('PDF 페이지를 찾을 수 없습니다.');697 698 const baseName = safeOutputBaseName(splitOutputName || baseNameOfFile(splitFile.name), '분리_문서');699 const downloads: DownloadItem[] = [];700 701 for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {702 const singlePageDoc = await PDFDocument.create();703 const [page] = await singlePageDoc.copyPages(source, [pageIndex]);704 singlePageDoc.addPage(page);705 const outBytes = await singlePageDoc.save({ useObjectStreams: true });706 const pageNumber = pageIndex + 1;707 const editableName = formatSplitRuleName(splitNameRule, baseName, pageNumber, pageCount);708 downloads.push({709 id: crypto.randomUUID(),710 label: `${pageNumber}페이지`,711 fileName: `${editableName}.pdf`,712 blob: new Blob([outBytes], { type: 'application/pdf' }),713 note: `원본 PDF의 ${pageNumber}페이지를 단일 PDF로 저장합니다.`,714 emphasis: pageIndex === 0,715 editableName,716 pageNumber,717 totalPages: pageCount,718 });719 }720 721 setSplitDownloads(downloads);722 setSplitSuccess(`${pageCount}개 페이지를 각각의 PDF로 분리했습니다.`);723 } catch (err: any) {724 setSplitError(err.message || 'PDF 분리 중 오류가 발생했습니다.');725 } finally {726 setIsSplitting(false);727 }728 };729 730 // ── Outline handlers ──731 const setOutlineTarget = (f: File) => {732 const ext = f.name.split('.').pop()?.toLowerCase();733 if (ext !== 'pdf' && ext !== 'ai') {734 setOutlineError('PDF 또는 AI 파일만 지원합니다.');735 return;736 }737 setOutlineFile(f);738 setOutlineName(f.name.substring(0, f.name.lastIndexOf('.')) || f.name);739 setOutlineError(null);740 setOutlineSuccess(null);741 setOutlineDownloads([]);742 setOutlineResultName('');743 };744 745 const resetOutlineWork = () => {746 setOutlineFile(null);747 setOutlineName('');748 setOutlineError(null);749 setOutlineSuccess(null);750 setOutlineDownloads([]);751 setOutlineResultName('');752 };753 754 const runOutlining = async () => {755 if (!outlineFile) return;756 const api = (window as any).electronAPI;757 setIsOutlining(true);758 setOutlineError(null);759 setOutlineSuccess(null);760 try {761 if (api) {762 // Desktop Electron environment763 const saveDir = await api.selectDirectory();764 if (!saveDir) { setIsOutlining(false); return; }765 766 let filePath = '';767 if (api.getPathForFile) filePath = api.getPathForFile(outlineFile);768 else filePath = (outlineFile as any).path;769 if (!filePath) throw new Error('파일 경로를 읽을 수 없습니다.');770 771 const result = await api.processOutline({772 filePath,773 saveDirectory: saveDir,774 baseName: outlineName.trim() || '출력문서',775 });776 777 if (result.success) {778 setOutlineDownloads([]);779 setOutlineSuccess(`로컬 저장 완료: ${saveDir}. 인쇄용 AI는 Illustrator 대지 인식을 돕기 위한 PDF 기반 호환 파일입니다.`);780 setOutlineFile(null);781 setOutlineName('');782 } else {783 setOutlineError(result.error || '변환 중 오류가 발생했습니다.');784 }785 } else {786 // Web Browser environment787 const API_URL = window.location.hostname === 'localhost'788 ? 'http://localhost:8080'789 : (window.location.hostname.includes('vercel.app')790 ? 'https://smart-pdf-ai-merger.onrender.com'791 : window.location.origin);792 793 const formData = new FormData();794 formData.append('file', outlineFile);795 796 const response = await fetch(`${API_URL}/process-outline`, {797 method: 'POST',798 body: formData,799 });800 801 if (!response.ok) {802 const errData = await response.json().catch(() => ({ error: '서버 연결에 실패했습니다.' }));803 throw new Error(errData.error || '아웃라인 서버 처리 오류');804 }805 806 const result = await response.json();807 if (result.success) {808 const byteCharacters = atob(result.fileData);809 const byteNumbers = new Array(byteCharacters.length);810 for (let i = 0; i < byteCharacters.length; i++) {811 byteNumbers[i] = byteCharacters.charCodeAt(i);812 }813 const byteArray = new Uint8Array(byteNumbers);814 const outlinedBlob = new Blob([byteArray], { type: 'application/pdf' });815 const originalBlob = outlineFile;816 const baseName = outlineName.trim() || result.originalName || '출력문서';817 818 const originalExt = extensionOfName(outlineFile.name);819 const downloads: DownloadItem[] = [820 {821 id: crypto.randomUUID(),822 label: '원본 파일',823 fileName: `(원본)${baseName}.${originalExt || 'pdf'}`,824 blob: originalBlob,825 note: '업로드한 원본 그대로 저장합니다.',826 },827 ...(originalExt !== 'pdf' ? [{828 id: crypto.randomUUID(),829 label: '원본 PDF',830 fileName: `(원본)${baseName}.pdf`,831 blob: originalBlob,832 note: 'Illustrator/PDF 호환 워크플로우용 원본 PDF 사본입니다.',833 }] : []),834 {835 id: crypto.randomUUID(),836 label: '인쇄용 PDF',837 fileName: `(인쇄용)${baseName}.pdf`,838 blob: outlinedBlob,839 note: '페이지 박스를 유지하고 폰트를 아웃라인 처리한 인쇄용 PDF입니다.',840 emphasis: true,841 },842 {843 id: crypto.randomUUID(),844 label: '인쇄용 AI 호환',845 fileName: `(인쇄용)${baseName}.ai`,846 blob: outlinedBlob,847 note: 'Illustrator가 대지로 인식하기 쉽도록 만든 PDF 기반 AI 호환 파일입니다.',848 emphasis: true,849 },850 ];851 852 setOutlineDownloads(downloads);853 setOutlineResultName(baseName);854 setOutlineSuccess(`변환이 완료되었습니다. 인쇄용 AI는 Illustrator 대지 인식을 돕기 위한 PDF 기반 호환 파일입니다.`);855 setOutlineFile(null);856 setOutlineName('');857 } else {858 setOutlineError(result.error || '변환 중 오류가 발생했습니다.');859 }860 }861 } catch (err: any) {862 setOutlineError(err.message || '처리 중 오류가 발생했습니다.');863 } finally {864 setIsOutlining(false);865 }866 };867 868 const downloadOutlineItem = (item: DownloadItem) => {869 downloadBlob(item.blob, item.fileName);870 };871 872 const downloadSplitItem = (item: DownloadItem) => {873 downloadBlob(item.blob, splitDownloadFileName(item));874 };875 876 const updateSplitDownloadName = (id: string, value: string) => {877 setSplitDownloads(items => items.map(item => (878 item.id === id ? { ...item, editableName: value.replace(/[\\/:*?"<>|]/g, '_') } : item879 )));880 };881 882 const applySplitNameRule = () => {883 if (splitDownloads.length === 0) return;884 const baseName = safeOutputBaseName(splitOutputName || (splitFile ? baseNameOfFile(splitFile.name) : ''), '분리_문서');885 setSplitDownloads(items => items.map((item, index) => {886 const totalPages = item.totalPages || items.length;887 const pageNumber = item.pageNumber || index + 1;888 const editableName = formatSplitRuleName(splitNameRule, baseName, pageNumber, totalPages);889 return {890 ...item,891 editableName,892 fileName: `${editableName}.pdf`,893 pageNumber,894 totalPages,895 };896 }));897 };898 899 const openWorkspaceItem = (item: WorkspaceItem) => {900 if (item.tab === 'images' && item.imageMode) {901 setImageInitialMode(item.imageMode);902 }903 setActiveTab(item.tab);904 };905 906 const downloadOutlineZip = async () => {907 if (outlineDownloads.length === 0) return;908 const zip = new JSZip();909 outlineDownloads.forEach(item => zip.file(item.fileName, item.blob));910 zip.file(911 'README.txt',912 '인쇄용 AI는 네이티브 AI가 아니라 Illustrator 대지 인식을 돕기 위한 PDF 기반 호환 파일입니다.\n파일별 Illustrator 해석 차이가 있을 수 있으니 최종 인쇄 전 (인쇄용).pdf와 함께 확인해주세요.\n'913 );914 const zipBlob = await zip.generateAsync({ type: 'blob' });915 downloadBlob(zipBlob, `${outlineResultName || '인쇄용_변환_결과'}.zip`);916 };917 918 const downloadSplitZip = async () => {919 if (splitDownloads.length === 0) return;920 const zip = new JSZip();921 const usedNames = new Map<string, number>();922 splitDownloads.forEach(item => {923 zip.file(uniqueFileName(splitDownloadFileName(item), usedNames), item.blob);924 });925 zip.file(926 'README.txt',927 `PDF 페이지 분리 결과입니다.\n원본 파일: ${splitFile?.name || 'unknown.pdf'}\n분리 페이지 수: ${splitDownloads.length}\n`928 );929 const zipBlob = await zip.generateAsync({ type: 'blob' });930 const baseName = safeOutputBaseName(splitOutputName || (splitFile ? baseNameOfFile(splitFile.name) : ''), '분리_문서');931 downloadBlob(zipBlob, `${baseName}_pages.zip`);932 };933 934 const splitRuleBaseName = safeOutputBaseName(splitOutputName || (splitFile ? baseNameOfFile(splitFile.name) : ''), '분리_문서');935 const splitRulePreview = `${formatSplitRuleName(936 splitNameRule,937 splitRuleBaseName,938 splitDownloads[0]?.pageNumber || 1,939 splitDownloads[0]?.totalPages || splitDownloads.length || 4940 )}.pdf`;941 const layoutMode = getLayoutMode(activeTab, {942 hasCompareResults: Boolean(compareResults?.length),943 compareExpanded,944 });945 946 return (947 <AppShell activeTab={activeTab} layoutMode={layoutMode} onTabChange={setActiveTab}>948 949 {/* ── HOME DASHBOARD ── */}950 <div style={{ display: activeTab === 'home' ? 'flex' : 'none' }} className="h-full w-full animate-fadeIn bg-app text-primary">951 <aside className="flex h-full w-[72px] flex-shrink-0 flex-col items-center border-r border-border bg-panel px-2 py-4">952 <button953 onClick={() => setActiveTab('home')}954 className="mb-8 flex h-11 w-11 items-center justify-center rounded-panel border border-violet-100 bg-panel shadow-panel transition hover:-translate-y-0.5 hover:border-violet-200 hover:shadow-floating focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus"955 aria-label="GANI FLOW 홈"956 title="GANI FLOW"957 >958 <img src={GANI_FLOW_MARK_URL} alt="" aria-hidden="true" className="h-9 w-9" draggable={false} />959 </button>960 961 <div className="flex flex-1 flex-col items-center gap-3">962 {homeRailItems.map(item => {963 const Icon = item.icon;964 const active = item.tab === activeTab;965 return (966 <button967 key={item.label}968 type="button"969 onClick={() => item.tab && setActiveTab(item.tab)}970 aria-label={`${item.label}${item.beta ? ' 베타' : ''}`}971 className={cn(972 'group flex w-full flex-col items-center gap-1 rounded-control px-1.5 py-2 text-[10px] font-bold transition-colors',973 active ? 'bg-selected text-primary' : 'text-muted hover:bg-subtle hover:text-primary',974 item.muted && !active && 'text-disabled-text'975 )}976 title={item.label}977 >978 <Icon className="h-4 w-4" />979 <span className="flex items-center gap-0.5 leading-none">980 <span>{item.label}</span>981 {item.beta && (982 <span className="rounded bg-gif-subtle px-1 py-0.5 text-[7px] font-extrabold tracking-wide text-gif">983 BETA984 </span>985 )}986 </span>987 </button>988 );989 })}990 </div>991 992 <div className="flex flex-col items-center gap-2">993 <button className="flex h-9 w-9 items-center justify-center rounded-control text-muted transition-colors hover:bg-subtle hover:text-primary" title="설정">994 <Settings className="h-4 w-4" />995 </button>996 <button className="flex h-9 w-9 items-center justify-center rounded-control text-muted transition-colors hover:bg-subtle hover:text-primary" title="더보기">997 <MoreHorizontal className="h-4 w-4" />998 </button>999 </div>1000 </aside>1001 1002 <section className="relative flex min-w-0 flex-1 flex-col overflow-hidden">1003 <div className="absolute inset-0 bg-[radial-gradient(circle_at_1px_1px,rgba(15,23,42,0.025)_1px,transparent_0)] [background-size:22px_22px]" />1004 <div className="relative h-14 flex-shrink-0" aria-hidden="true" />1005 1006 <div className="relative flex flex-1 flex-col items-center justify-center px-6 py-8">1007 <motion.div1008 initial={{ opacity: 0, y: 12 }}1009 animate={{ opacity: 1, y: 0 }}1010 className="w-full max-w-[1220px] -translate-y-[6vh] lg:-translate-y-[9vh]"1011 >1012 <div className="mx-auto max-w-[820px] origin-center translate-y-2 scale-[1.15] px-1 text-center">1013 <h1 className="flex justify-center">1014 <GaniFlowLogo showDescriptor />1015 </h1>1016 <p className="mt-4 text-sm font-medium text-slate-500">1017 파일 정리, 변환, 검수, 이미지 작업을 한 흐름으로 시작합니다.1018 </p>1019 </div>1020 1021 <div className="mx-auto mt-14 w-full max-w-full lg:w-fit">1022 <div className="overflow-x-auto lg:overflow-visible">1023 <div className="flex min-w-full w-max flex-nowrap justify-center px-7 py-6 md:px-8">1024 {workspaceGroups.map(group => (1025 <div key={group.label} className="relative flex-shrink-0 w-fit min-w-0 px-5 py-2 after:hidden after:absolute after:bottom-2 after:right-0 after:top-2 after:w-px after:bg-border-strong lg:py-1 lg:after:block first:lg:pl-0 last:lg:pr-0 last:after:hidden">1026 <p className="mb-4 text-center text-xs font-normal text-muted">{group.label}</p>1027 <div className="flex flex-nowrap justify-center gap-3">1028 {uniqueWorkspaceItems(group.items).map(item => {1029 const Icon = item.icon;1030 const itemKey = workspaceItemKey(item);1031 const previewVisible = hoveredWorkspaceKey === itemKey;1032 return (1033 <button1034 key={itemKey}1035 type="button"1036 onClick={() => openWorkspaceItem(item)}1037 aria-label={`${item.title}${item.beta ? ' 베타' : ''} 작업 열기`}1038 onMouseEnter={() => setHoveredWorkspaceKey(itemKey)}1039 onMouseLeave={() => setHoveredWorkspaceKey(current => current === itemKey ? null : current)}1040 onFocus={() => setHoveredWorkspaceKey(itemKey)}1041 onBlur={() => setHoveredWorkspaceKey(current => current === itemKey ? null : current)}1042 className="group relative flex w-[98px] flex-col items-center gap-2 rounded-panel px-2 py-2.5 text-center transition hover:bg-subtle hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus"1043 >1044 {previewVisible && <WorkspaceHoverPreview item={item} />}1045 <span className="flex h-10 w-10 items-center justify-center transition-transform group-hover:-translate-y-0.5 group-hover:scale-110">1046 <Icon className={cn('h-5 w-5', item.color)} />1047 </span>1048 <span className="flex flex-nowrap items-center justify-center gap-1 whitespace-nowrap text-xs font-bold text-slate-900">1049 <span>{item.title}</span>1050 {item.beta && (1051 <span className="rounded-md bg-gif-subtle px-1.5 py-0.5 text-[8px] font-extrabold tracking-wide text-gif">1052 BETA1053 </span>1054 )}1055 </span>1056 </button>1057 );1058 })}1059 </div>1060 </div>1061 ))}1062 </div>1063 </div>1064 </div>1065 </motion.div>1066 </div>1067 </section>1068 </div>1069 1070 <div style={{ display: 'none' }} className="w-full animate-fadeIn">1071 <section className="relative mx-auto max-w-6xl overflow-hidden rounded-[2rem] border border-white/70 bg-white/70 p-6 shadow-sm sm:p-9">1072 <div className="pointer-events-none absolute inset-0 opacity-70 [background-image:linear-gradient(rgba(15,23,42,.045)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,.045)_1px,transparent_1px)] [background-size:28px_28px]" />1073 <div className="pointer-events-none absolute -right-20 -top-24 h-72 w-72 rounded-full bg-amber-200/50 blur-3xl" />1074 <div className="pointer-events-none absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-sky-200/50 blur-3xl" />1075 1076 <div className="relative flex flex-col gap-4 md:flex-row md:items-end md:justify-between">1077 <div>1078 <p className="text-xs font-black uppercase tracking-[0.28em] text-gray-400">Print Studio Desk</p>1079 <h1 className="mt-3 text-3xl font-black tracking-tight text-gray-950 sm:text-5xl">필요한 인쇄 도구를 바로 시작하세요</h1>1080 <p className="mt-4 max-w-2xl text-sm font-medium leading-6 text-gray-500">1081 병합, 인쇄용 변환, 수정 검수, 파일 확인을 한 화면에서 고르고 작업 흐름을 이어갑니다.1082 </p>1083 </div>1084 <div className="rounded-2xl border border-gray-200/80 bg-white/80 px-4 py-3 text-xs font-bold text-gray-500 shadow-sm">1085 최근 작업 기록은 다음 버전에서 추가하고, 이번 화면은 빠른 진입에 집중했습니다.1086 </div>1087 </div>1088 1089 <div className="relative mt-8 grid gap-4 md:grid-cols-2">1090 {featureCards.map((card, index) => {1091 const Icon = card.icon;1092 return (1093 <motion.button1094 key={card.tab}1095 type="button"1096 initial={{ opacity: 0, y: 16 }}1097 animate={{ opacity: 1, y: 0 }}1098 transition={{ delay: index * 0.04 }}1099 onClick={() => setActiveTab(card.tab)}1100 className="group relative overflow-hidden rounded-3xl border border-gray-200/80 bg-white/90 p-5 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-gray-300 hover:shadow-xl"1101 >1102 <div className={cn('absolute inset-x-0 top-0 h-1.5 bg-gradient-to-r', card.accent)} />1103 <div className="flex items-start justify-between gap-4">1104 <div className={cn('rounded-2xl bg-gradient-to-br p-3 text-white shadow-sm', card.accent)}>1105 <Icon className="h-5 w-5" />1106 </div>1107 <span className="rounded-full bg-gray-100 px-3 py-1 text-[11px] font-black uppercase tracking-wide text-gray-500">{card.eyebrow}</span>1108 </div>1109 <h2 className="mt-5 text-xl font-black tracking-tight text-gray-950">{card.title}</h2>1110 <p className="mt-2 min-h-12 text-sm font-medium leading-6 text-gray-500">{card.description}</p>1111 <div className="mt-5 grid gap-2 rounded-2xl border border-gray-100 bg-gray-50/70 p-3 text-xs font-bold text-gray-500">1112 <div className="flex justify-between gap-3">1113 <span className="text-gray-400">지원 포맷</span>1114 <span className="text-gray-800">{card.formats}</span>1115 </div>1116 <div className="flex justify-between gap-3">1117 <span className="text-gray-400">사용 목적</span>1118 <span className="text-right text-gray-800">{card.purpose}</span>1119 </div>1120 </div>1121 <div className="mt-5 flex items-center justify-between">1122 <span className="text-sm font-black text-gray-900">{card.cta}</span>1123 <span className="rounded-full bg-gray-900 p-2 text-white transition group-hover:translate-x-1">1124 <ArrowRight className="h-4 w-4" />1125 </span>1126 </div>1127 </motion.button>1128 );1129 })}1130 </div>1131 </section>1132 </div>1133 1134 {/* ════ TAB 1: MERGE ════ */}1135 <div style={{ display: activeTab === 'merge' ? 'flex' : 'none' }} className="flex w-full flex-col gap-6">1136 {mergeFiles.length === 0 ? (1137 <FileWorkflowGate1138 title="PDF 병합"1139 description="PDF와 AI 파일을 순서대로 하나의 PDF로 합칩니다."1140 featureIcon={<Files className="size-5" />}1141 featureIconClassName="text-sky-500"1142 uploadTitle="병합할 파일을 드래그하거나 클릭하여 선택"1143 uploadDescription=".pdf · .ai · .png · .jpg · .webp · .gif · .bmp · .svg"1144 accept=".pdf,.ai,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,image/*"1145 multiple1146 onFiles={addMergeFiles}1147 />1148 ) : (1149 <PageHeader1150 title="PDF 병합"1151 description="PDF와 AI 파일을 순서대로 하나의 PDF로 합칩니다."1152 icon={<Files className="size-5" />}1153 iconClassName="text-sky-500"1154 actions={(1155 <>1156 <input1157 ref={mergeInputRef}1158 type="file"1159 multiple1160 accept=".pdf,.ai,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,image/*"1161 className="sr-only"1162 onChange={e => {1163 addMergeFiles(e.target.files);1164 e.currentTarget.value = '';1165 }}1166 />1167 <Button variant="outline" size="sm" onClick={() => mergeInputRef.current?.click()} startIcon={<FilePlus2 className="size-4" />}>1168 파일 추가1169 </Button>1170 <NewWorkButton onConfirm={resetMergeWork} />1171 </>1172 )}1173 />1174 )}1175 1176 {/* Error */}1177 <AnimatePresence>1178 {mergeError && (1179 <motion.div initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }}>1180 <Alert1181 variant="danger"1182 icon={<AlertCircle className="size-4" />}1183 onDismiss={() => setMergeError(null)}1184 dismissLabel="병합 오류 닫기"1185 >1186 {mergeError}1187 </Alert>1188 </motion.div>1189 )}1190 </AnimatePresence>1191 1192 {/* File list */}1193 {mergeFiles.length > 0 && (1194 <Panel>1195 <PanelHeader className="flex items-center justify-between bg-subtle py-3">1196 <div className="flex min-w-0 items-center gap-3">1197 <span className="shrink-0 text-[12px] font-semibold text-secondary">{mergeFiles.length}개 파일</span>1198 {mergeSelectedIds.size > 0 && (1199 <span className="shrink-0 rounded-control bg-selected px-2 py-1 text-[11px] font-bold text-primary">1200 {mergeSelectedIds.size}개 선택