Skip to main content
Glama
+page.svelte26.8 kB
<script> import { onMount } from 'svelte'; import { page } from '$app/stores'; let documents = []; let filteredDocuments = []; let loading = true; let error = null; // 새 문서 생성 폼 상태 let showCreateForm = false; let createFormData = { title: '', content: '', doc_type: '', category: '', tags: '', summary: '' }; let availableCategories = []; let availableTags = []; let creatingDocument = false; // 문서 편집 폼 상태 let showEditForm = false; let editFormData = { id: null, title: '', content: '', doc_type: '', category: '', tags: '', summary: '', status: '' }; let updatingDocument = false; // 필터 상태 let filters = { search: '', doc_type: '', category: '', status: '' }; // 정렬 옵션 let sortBy = 'updated'; // 'updated', 'created', 'title' // 문서 유형 옵션 const docTypes = [ 'test_guide', 'test_results', 'analysis', 'report', 'checklist', 'specification', 'meeting_notes', 'decision_log' ]; // 상태 옵션 const statusOptions = ['draft', 'review', 'approved', 'archived']; onMount(async () => { await Promise.all([ loadDocuments(), loadDocumentCategories() ]); }); async function loadDocuments() { try { loading = true; const response = await fetch('/api/documents'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); documents = data.documents || []; filterDocuments(); } catch (err) { console.error('Error loading documents:', err); error = err.message; documents = []; } finally { loading = false; } } function filterDocuments() { // 필터링 let filtered = documents.filter(doc => { const matchesSearch = !filters.search || doc.title.toLowerCase().includes(filters.search.toLowerCase()) || (doc.summary && doc.summary.toLowerCase().includes(filters.search.toLowerCase())); const matchesType = !filters.doc_type || doc.doc_type === filters.doc_type; const matchesCategory = !filters.category || doc.category === filters.category; const matchesStatus = !filters.status || doc.status === filters.status; return matchesSearch && matchesType && matchesCategory && matchesStatus; }); // 정렬 filteredDocuments = filtered.sort((a, b) => { switch (sortBy) { case 'updated': return new Date(b.updated_at) - new Date(a.updated_at); case 'created': return new Date(b.created_at) - new Date(a.created_at); case 'title': return a.title.localeCompare(b.title); default: return new Date(b.updated_at) - new Date(a.updated_at); } }); } function handleFilterChange() { filterDocuments(); } // 정렬 옵션 변경 시 재정렬 $: if (sortBy && documents.length > 0) { filterDocuments(); } function getUniqueCategories() { const categories = [...new Set(documents.map(doc => doc.category).filter(Boolean))]; return categories; } function formatDate(dateString) { if (!dateString) return '-'; return new Date(dateString).toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } function getDocTypeDisplayName(type) { const typeMap = { 'test_guide': '테스트 가이드', 'test_results': '테스트 결과', 'analysis': '분석', 'report': '보고서', 'checklist': '체크리스트', 'specification': '사양서', 'meeting_notes': '회의록', 'decision_log': '의사결정 로그' }; return typeMap[type] || type; } function getStatusDisplayName(status) { const statusMap = { 'draft': '초안', 'review': '검토 중', 'approved': '승인됨', 'archived': '보관됨' }; return statusMap[status] || status; } function getStatusBadgeClass(status) { const statusClasses = { 'draft': 'bg-gray-100 text-gray-800', 'review': 'bg-yellow-100 text-yellow-800', 'approved': 'bg-green-100 text-green-800', 'archived': 'bg-blue-100 text-blue-800' }; return statusClasses[status] || 'bg-gray-100 text-gray-800'; } // 문서 분류 정보 로드 async function loadDocumentCategories() { try { const response = await fetch('/api/document-categories'); if (response.ok) { const data = await response.json(); availableCategories = data.categories || []; availableTags = data.tags || []; } } catch (err) { console.error('Error loading document categories:', err); } } // 새 문서 생성 async function createDocument() { if (!createFormData.title.trim() || !createFormData.content.trim()) { alert('제목과 내용을 입력해주세요.'); return; } try { creatingDocument = true; // 태그를 배열로 변환 const tags = createFormData.tags .split(',') .map(tag => tag.trim()) .filter(tag => tag.length > 0); const response = await fetch('/api/documents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: createFormData.title, content: createFormData.content, doc_type: createFormData.doc_type || 'analysis', category: createFormData.category, tags: tags, summary: createFormData.summary }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to create document'); } const result = await response.json(); // 폼 초기화 createFormData = { title: '', content: '', doc_type: '', category: '', tags: '', summary: '' }; showCreateForm = false; // 문서 목록 새로고침 await loadDocuments(); alert('문서가 성공적으로 생성되었습니다!'); } catch (err) { console.error('Error creating document:', err); alert('문서 생성 중 오류가 발생했습니다: ' + err.message); } finally { creatingDocument = false; } } // 편집 모달 열기 function openEditModal(doc) { editFormData = { id: doc.id, title: doc.title, content: doc.content || '', doc_type: doc.doc_type || '', category: doc.category || '', tags: Array.isArray(doc.tags) ? doc.tags.join(', ') : (doc.tags || ''), summary: doc.summary || '', status: doc.status || 'draft' }; showEditForm = true; } // 문서 업데이트 async function updateDocument() { if (!editFormData.title.trim() || !editFormData.content.trim()) { alert('제목과 내용을 입력해주세요.'); return; } try { updatingDocument = true; // 태그를 배열로 변환 const tags = editFormData.tags .split(',') .map(tag => tag.trim()) .filter(tag => tag.length > 0); const response = await fetch(`/api/documents/${editFormData.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: editFormData.title, content: editFormData.content, doc_type: editFormData.doc_type || 'analysis', category: editFormData.category, tags: tags, summary: editFormData.summary, status: editFormData.status }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Failed to update document'); } const result = await response.json(); // 폼 초기화 editFormData = { id: null, title: '', content: '', doc_type: '', category: '', tags: '', summary: '', status: '' }; showEditForm = false; // 문서 목록 새로고침 await loadDocuments(); alert('문서가 성공적으로 수정되었습니다!'); } catch (err) { console.error('Error updating document:', err); alert('문서 수정 중 오류가 발생했습니다: ' + err.message); } finally { updatingDocument = false; } } </script> <svelte:head> <title>문서 관리 - WorkflowMCP</title> </svelte:head> <div class="space-y-6"> <div class="sm:flex sm:items-center sm:justify-between"> <div> <h1 class="text-2xl font-bold text-gray-900">문서 관리</h1> <p class="mt-2 text-sm text-gray-700">프로젝트 문서를 관리하고 검색하세요.</p> </div> <div class="mt-4 sm:mt-0 flex items-center gap-4"> <!-- 정렬 옵션 --> <div class="flex items-center gap-2"> <label class="text-sm text-gray-600">정렬:</label> <select bind:value={sortBy} class="text-sm border border-gray-300 rounded px-2 py-1 bg-white" > <option value="updated">최근 수정</option> <option value="created">최근 등록</option> <option value="title">제목 순</option> </select> </div> <button type="button" on:click={() => showCreateForm = true} class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 mr-2" > <svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> </svg> 새 문서 </button> <button type="button" on:click={loadDocuments} class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" > 새로고침 </button> </div> </div> <!-- 필터 섹션 --> <div class="bg-white shadow rounded-lg p-6"> <h3 class="text-lg font-medium text-gray-900 mb-4">필터</h3> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div> <label for="search" class="block text-sm font-medium text-gray-700">검색</label> <input id="search" type="text" bind:value={filters.search} on:input={handleFilterChange} placeholder="제목 또는 요약으로 검색..." class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" /> </div> <div> <label for="doc_type" class="block text-sm font-medium text-gray-700">문서 유형</label> <select id="doc_type" bind:value={filters.doc_type} on:change={handleFilterChange} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" > <option value="">모든 유형</option> {#each docTypes as type} <option value={type}>{getDocTypeDisplayName(type)}</option> {/each} </select> </div> <div> <label for="category" class="block text-sm font-medium text-gray-700">카테고리</label> <select id="category" bind:value={filters.category} on:change={handleFilterChange} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" > <option value="">모든 카테고리</option> {#each getUniqueCategories() as category} <option value={category}>{category}</option> {/each} </select> </div> <div> <label for="status" class="block text-sm font-medium text-gray-700">상태</label> <select id="status" bind:value={filters.status} on:change={handleFilterChange} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" > <option value="">모든 상태</option> {#each statusOptions as status} <option value={status}>{getStatusDisplayName(status)}</option> {/each} </select> </div> </div> </div> <!-- 문서 목록 --> <div class="bg-white shadow rounded-lg"> <div class="px-6 py-4 border-b border-gray-200"> <h3 class="text-lg font-medium text-gray-900"> 문서 목록 {#if !loading} <span class="text-sm text-gray-500">({filteredDocuments.length}개)</span> {/if} </h3> </div> {#if loading} <div class="px-6 py-12 text-center"> <div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div> <p class="mt-2 text-sm text-gray-600">문서를 불러오는 중...</p> </div> {:else if error} <div class="px-6 py-12 text-center"> <div class="text-red-400 mb-4"> <svg class="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" /> </svg> </div> <h3 class="text-sm font-medium text-gray-900">오류 발생</h3> <p class="mt-1 text-sm text-gray-500">{error}</p> <button on:click={loadDocuments} class="mt-4 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200" > 다시 시도 </button> </div> {:else if filteredDocuments.length === 0} <div class="px-6 py-12 text-center"> <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> </svg> <h3 class="mt-2 text-sm font-medium text-gray-900">문서가 없습니다</h3> <p class="mt-1 text-sm text-gray-500"> {documents.length === 0 ? '아직 생성된 문서가 없습니다.' : '필터 조건에 맞는 문서가 없습니다.'} </p> </div> {:else} <div class="overflow-hidden"> <ul class="divide-y divide-gray-200"> {#each filteredDocuments as doc (doc.id)} <li class="px-6 py-4 hover:bg-gray-50"> <div class="flex items-center justify-between"> <div class="flex-1 min-w-0"> <div class="flex items-center space-x-3"> <h4 class="text-sm font-medium text-gray-900 truncate"> {doc.title} </h4> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getStatusBadgeClass(doc.status)}"> {getStatusDisplayName(doc.status)} </span> </div> <div class="mt-1 flex items-center space-x-4 text-sm text-gray-500"> <span>📋 {getDocTypeDisplayName(doc.doc_type)}</span> {#if doc.category} <span>📂 {doc.category}</span> {/if} <span>🆔 {doc.id}</span> </div> {#if doc.summary} <p class="mt-2 text-sm text-gray-600 line-clamp-2"> {doc.summary} </p> {/if} <div class="mt-2 flex items-center space-x-4 text-xs text-gray-400"> <span>생성: {formatDate(doc.created_at)}</span> <span>수정: {formatDate(doc.updated_at)}</span> {#if doc.linked_entities_count > 0} <span>🔗 {doc.linked_entities_count}개 연결</span> {/if} </div> </div> <div class="flex-shrink-0 ml-4 space-x-2"> <button class="text-green-600 hover:text-green-900 text-sm font-medium" on:click={() => openEditModal(doc)} > 편집 </button> <button class="text-blue-600 hover:text-blue-900 text-sm font-medium" on:click={() => window.open(`/documents/${doc.id}`, '_blank')} > 상세보기 </button> </div> </div> </li> {/each} </ul> </div> {/if} </div> </div> <!-- 새 문서 생성 모달 --> {#if showCreateForm} <div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white"> <div class="flex items-center justify-between mb-6"> <h3 class="text-lg font-medium text-gray-900">새 문서 생성</h3> <button type="button" on:click={() => showCreateForm = false} class="text-gray-400 hover:text-gray-600" > <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> </svg> </button> </div> <form on:submit|preventDefault={createDocument} class="space-y-6"> <!-- 제목 --> <div> <label for="title" class="block text-sm font-medium text-gray-700">제목 *</label> <input id="title" type="text" bind:value={createFormData.title} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="문서 제목을 입력하세요" /> </div> <!-- 문서 유형 및 카테고리 --> <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div> <label for="doc_type" class="block text-sm font-medium text-gray-700">문서 유형</label> <select id="doc_type" bind:value={createFormData.doc_type} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" > <option value="">선택하세요</option> {#each docTypes as type} <option value={type}>{getDocTypeDisplayName(type)}</option> {/each} </select> </div> <div> <label for="category" class="block text-sm font-medium text-gray-700">카테고리</label> <input id="category" type="text" bind:value={createFormData.category} list="categoryList" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="카테고리 입력 또는 선택" /> <datalist id="categoryList"> {#each availableCategories as category} <option value={category.value}>{category.value} ({category.count}개)</option> {/each} </datalist> </div> </div> <!-- 태그 --> <div> <label for="tags" class="block text-sm font-medium text-gray-700">태그</label> <input id="tags" type="text" bind:value={createFormData.tags} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="태그를 쉼표로 구분하여 입력 (예: 테스트, 분석, 개발)" /> <p class="mt-1 text-xs text-gray-500"> 자주 사용되는 태그: {#each availableTags.slice(0, 5) as tag} <button type="button" on:click={() => { const currentTags = createFormData.tags.split(',').map(t => t.trim()).filter(t => t); if (!currentTags.includes(tag.value)) { createFormData.tags = [...currentTags, tag.value].join(', '); } }} class="text-blue-600 hover:text-blue-800 mx-1" > {tag.value} </button> {/each} </p> </div> <!-- 요약 --> <div> <label for="summary" class="block text-sm font-medium text-gray-700">요약</label> <textarea id="summary" bind:value={createFormData.summary} rows="2" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="문서의 간단한 요약을 입력하세요" ></textarea> </div> <!-- 내용 --> <div> <label for="content" class="block text-sm font-medium text-gray-700">내용 *</label> <textarea id="content" bind:value={createFormData.content} rows="10" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="Markdown 형식으로 문서 내용을 작성하세요" ></textarea> </div> <!-- 버튼 --> <div class="flex justify-end space-x-3"> <button type="button" on:click={() => showCreateForm = false} class="px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50" > 취소 </button> <button type="submit" disabled={creatingDocument || !createFormData.title.trim() || !createFormData.content.trim()} class="px-4 py-2 border border-transparent rounded-md shadow-sm bg-blue-600 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50" > {#if creatingDocument} <div class="inline-flex items-center"> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> 생성 중... </div> {:else} 문서 생성 {/if} </button> </div> </form> </div> </div> {/if} <!-- 문서 편집 모달 --> {#if showEditForm} <div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white"> <div class="flex items-center justify-between mb-6"> <h3 class="text-lg font-medium text-gray-900">문서 편집</h3> <button type="button" on:click={() => showEditForm = false} class="text-gray-400 hover:text-gray-600" > <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> </svg> </button> </div> <form on:submit|preventDefault={updateDocument} class="space-y-6"> <!-- 제목 --> <div> <label for="edit-title" class="block text-sm font-medium text-gray-700">제목 *</label> <input id="edit-title" type="text" bind:value={editFormData.title} required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="문서 제목을 입력하세요" /> </div> <!-- 문서 유형, 카테고리, 상태 --> <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div> <label for="edit-doc_type" class="block text-sm font-medium text-gray-700">문서 유형</label> <select id="edit-doc_type" bind:value={editFormData.doc_type} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" > <option value="">선택하세요</option> {#each docTypes as type} <option value={type}>{getDocTypeDisplayName(type)}</option> {/each} </select> </div> <div> <label for="edit-category" class="block text-sm font-medium text-gray-700">카테고리</label> <input id="edit-category" type="text" bind:value={editFormData.category} list="editCategoryList" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="카테고리 입력 또는 선택" /> <datalist id="editCategoryList"> {#each availableCategories as category} <option value={category.value}>{category.value} ({category.count}개)</option> {/each} </datalist> </div> <div> <label for="edit-status" class="block text-sm font-medium text-gray-700">상태</label> <select id="edit-status" bind:value={editFormData.status} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" > {#each statusOptions as status} <option value={status}>{getStatusDisplayName(status)}</option> {/each} </select> </div> </div> <!-- 태그 --> <div> <label for="edit-tags" class="block text-sm font-medium text-gray-700">태그</label> <input id="edit-tags" type="text" bind:value={editFormData.tags} class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="태그를 쉼표로 구분하여 입력 (예: 테스트, 분석, 개발)" /> <p class="mt-1 text-xs text-gray-500"> 자주 사용되는 태그: {#each availableTags.slice(0, 5) as tag} <button type="button" on:click={() => { const currentTags = editFormData.tags.split(',').map(t => t.trim()).filter(t => t); if (!currentTags.includes(tag.value)) { editFormData.tags = [...currentTags, tag.value].join(', '); } }} class="text-blue-600 hover:text-blue-800 mx-1" > {tag.value} </button> {/each} </p> </div> <!-- 요약 --> <div> <label for="edit-summary" class="block text-sm font-medium text-gray-700">요약</label> <textarea id="edit-summary" bind:value={editFormData.summary} rows="2" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="문서의 간단한 요약을 입력하세요" ></textarea> </div> <!-- 내용 --> <div> <label for="edit-content" class="block text-sm font-medium text-gray-700">내용 *</label> <textarea id="edit-content" bind:value={editFormData.content} rows="10" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" placeholder="Markdown 형식으로 문서 내용을 작성하세요" ></textarea> </div> <!-- 버튼 --> <div class="flex justify-end space-x-3"> <button type="button" on:click={() => showEditForm = false} class="px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50" > 취소 </button> <button type="submit" disabled={updatingDocument || !editFormData.title.trim() || !editFormData.content.trim()} class="px-4 py-2 border border-transparent rounded-md shadow-sm bg-green-600 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50" > {#if updatingDocument} <div class="inline-flex items-center"> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> 수정 중... </div> {:else} 문서 수정 {/if} </button> </div> </form> </div> </div> {/if}

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/foswmine/workflow-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server