CoolFace
Apppublic

KOOHAWN/AI_PDF_TOOLKIt

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
App.tsx1817 linesDownload Raw Back to src
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}개 선택

Showing the first 1,200 of 1817 lines. Download the file for the rest.