init
This commit is contained in:
173
src/pages/AiNewsPage.jsx
Normal file
173
src/pages/AiNewsPage.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function AiNewsPage() {
|
||||
const { user } = useAuth();
|
||||
const [news, setNews] = useState([]);
|
||||
const [newsOffset, setNewsOffset] = useState(0);
|
||||
const [newsLoading, setNewsLoading] = useState(false);
|
||||
const [showNewsEditor, setShowNewsEditor] = useState(false);
|
||||
const [newsUrl, setNewsUrl] = useState('');
|
||||
const contentRef = useRef(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [readyForScroll, setReadyForScroll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await loadNews(0, false);
|
||||
// 초기 진입 시 리스트 하단(최신)으로 이동
|
||||
requestAnimationFrame(() => {
|
||||
const el = contentRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
// 이미지 로드 등으로 높이가 늘어나는 경우를 대비해 한 번 더 보정
|
||||
setTimeout(() => {
|
||||
const el2 = contentRef.current;
|
||||
if (el2) el2.scrollTop = el2.scrollHeight;
|
||||
// 한 프레임 더 보장 후 스크롤 핸들러 활성화
|
||||
requestAnimationFrame(() => {
|
||||
setInitialized(true);
|
||||
setReadyForScroll(true);
|
||||
});
|
||||
}, 60);
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
if (!readyForScroll) return; // 초기 자동 스크롤 완료 전에는 무시
|
||||
if (el.scrollTop <= 20 && !newsLoading) {
|
||||
loadNews(newsOffset, true);
|
||||
}
|
||||
};
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [initialized, readyForScroll, newsOffset, newsLoading]);
|
||||
|
||||
const loadNews = async (offset = 0, prepend = false) => {
|
||||
if (newsLoading) return;
|
||||
setNewsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8010/community/ai_news?offset=${offset}&limit=10`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const items = (data.items || []).reverse();
|
||||
if (prepend) {
|
||||
const el = contentRef.current;
|
||||
const prevH = el ? el.scrollHeight : document.documentElement.scrollHeight;
|
||||
setNews((prev) => [...items, ...prev]);
|
||||
setTimeout(() => {
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight - prevH;
|
||||
} else {
|
||||
const newH = document.documentElement.scrollHeight;
|
||||
window.scrollTo(0, newH - prevH);
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
setNews(items);
|
||||
setTimeout(() => {
|
||||
const el = contentRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight; else window.scrollTo(0, document.documentElement.scrollHeight);
|
||||
}, 0);
|
||||
}
|
||||
const nextOffset = data.nextOffset || 0;
|
||||
setNewsOffset(nextOffset);
|
||||
return nextOffset;
|
||||
} finally {
|
||||
setNewsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 스크롤 보정 루프는 제거: 진입 직후 하단으로 고정만 수행
|
||||
|
||||
const submitNews = async () => {
|
||||
if (!newsUrl.trim()) return;
|
||||
await fetch('http://localhost:8010/community/ai_news', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: newsUrl, author_id: String(user?.user_id || ''), author_email: user?.email || null }),
|
||||
});
|
||||
setShowNewsEditor(false);
|
||||
setNewsUrl('');
|
||||
loadNews(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page-ai-news" style={{ flex: 1, display: 'flex', flexDirection: 'column', paddingTop: '24px' }}>
|
||||
<main className="community-content" ref={contentRef} style={{ overflowY: 'auto', flex: 1 }}>
|
||||
<div style={{ padding: '0 20px 50px 20px' }}>
|
||||
{news.map((n) => (
|
||||
<div key={n.id} style={{ background: '#fff', border: '1px solid #eee', borderRadius: 12, padding: 12, marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{n.meta?.image ? (
|
||||
<img src={n.meta.image} alt="thumb" style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 8 }} />
|
||||
) : null}
|
||||
<div style={{ flex: 1 }}>
|
||||
<a href={n.meta?.url || n.url} target="_blank" rel="noreferrer" style={{ fontWeight: 600, color: '#1976d2', textDecoration: 'none' }}>
|
||||
{n.meta?.title || n.url}
|
||||
</a>
|
||||
<div style={{ marginTop: 6, color: '#555', lineHeight: 1.5 }}>{n.meta?.description}</div>
|
||||
<div style={{ marginTop: 6, color: '#999', fontSize: '.9rem' }}>{new Date(n.created_at).toISOString().slice(0, 10)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{newsLoading && (
|
||||
<div className="loading-overlay">
|
||||
<span className="loading-text">뉴스 로딩중</span>
|
||||
<span className="loading-dots">
|
||||
<span className="loading-dot"></span>
|
||||
<span className="loading-dot"></span>
|
||||
<span className="loading-dot"></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user && !showNewsEditor && (
|
||||
<button
|
||||
onClick={() => setShowNewsEditor(true)}
|
||||
style={{ position: 'fixed', right: 24, bottom: 24, background: '#ff9800', color: '#fff', border: 'none', padding: '12px 16px', borderRadius: 18, cursor: 'pointer', boxShadow: '0 4px 16px rgba(0,0,0,.15)', zIndex: 1000 }}
|
||||
>
|
||||
뉴스등록
|
||||
</button>
|
||||
)}
|
||||
{showNewsEditor && user && (
|
||||
<div
|
||||
style={{ position: 'fixed', right: 24, bottom: 84, zIndex: 1001, width: 'min(560px, calc(100vw - 40px))', background: '#fff', border: '1px solid #ffd6c2', borderRadius: 12, padding: 12, boxShadow: '0 6px 18px rgba(0,0,0,.15)' }}
|
||||
>
|
||||
<input
|
||||
value={newsUrl}
|
||||
onChange={(e) => setNewsUrl(e.target.value)}
|
||||
placeholder="뉴스 URL을 입력하세요"
|
||||
style={{ width: 'calc(100% - 25px)', marginBottom: 8, padding: '10px 12px', borderRadius: 8, border: '1px solid #ffd6c2' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitNews();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewsEditor(false);
|
||||
setNewsUrl('');
|
||||
}}
|
||||
style={{ background: '#fff', border: '1px solid #ddd', padding: '8px 12px', borderRadius: 8 }}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button onClick={submitNews} style={{ background: '#ff9800', color: '#fff', border: 'none', padding: '8px 12px', borderRadius: 8 }}>등록</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
547
src/pages/ChatPage.jsx
Normal file
547
src/pages/ChatPage.jsx
Normal file
@@ -0,0 +1,547 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTool } from '../context/ToolContext';
|
||||
import DevChatInput from '../tools/dev_chatbot/ChatInput.jsx';
|
||||
import { marked } from 'marked';
|
||||
marked.setOptions({ mangle:false, headerIds:false });
|
||||
import { fetchOpenAIChat, fetchOpenAIImage, classifyRequest } from '../services/openaiService';
|
||||
|
||||
// === Chat History Panel 컴포넌트 추가 ===
|
||||
function ChatHistoryPanel({ toolId }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
|
||||
// 패널이 열릴 때마다 로컬스토리지에서 메시지 불러오기
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(`messages_${toolId}`) || '[]');
|
||||
setMessages(Array.isArray(saved) ? saved : []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
}, [toolId, open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="chat-history-toggle" onClick={() => setOpen(!open)} aria-label="대화 히스토리 토글">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<div className={`chat-history-panel ${open ? 'show' : ''}`}>
|
||||
<div style={{ padding: '12px', fontWeight: 'bold', borderBottom: '1px solid #ffe8d5' }}>대화 히스토리</div>
|
||||
<ul className="chat-history-list">
|
||||
{messages.length === 0 && (
|
||||
<li style={{ padding: '12px', color: '#999' }}>대화 기록이 없습니다.</li>
|
||||
)}
|
||||
{messages.map((m, idx) => (
|
||||
<li key={idx} className="history-item">
|
||||
<div className="history-content">{String(m.content).slice(0, 36)}{String(m.content).length > 36 ? '…' : ''}</div>
|
||||
<div className="history-time">{m.time}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileSidebar() {
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8010/files');
|
||||
const data = await res.json();
|
||||
setFiles(data.files || []);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (fname) => {
|
||||
if (!window.confirm(`${fname} 파일을 삭제하시겠습니까?`)) return;
|
||||
await fetch(`http://localhost:8010/file?filename=${encodeURIComponent(fname)}`, { method: 'DELETE' });
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
const formData = new FormData();
|
||||
files.forEach((f) => formData.append('files', f));
|
||||
await fetch('http://localhost:8010/upload_pdf', { method: 'POST', body: formData });
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-sidebar">
|
||||
<div className="file-sidebar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>파일 리스트</span>
|
||||
<button className="file-add-btn" onClick={() => document.getElementById('dev-file-input').click()}>+</button>
|
||||
<input id="dev-file-input" type="file" multiple style={{ display: 'none' }} onChange={handleUpload} />
|
||||
</div>
|
||||
<ul className="file-list">
|
||||
{files.map((f) => (
|
||||
<li key={f} title={f} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f}</span>
|
||||
<button className="file-del-btn" onClick={() => handleDelete(f)}>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { selectedTool } = useTool();
|
||||
const [messages, setMessages] = useState(() => {
|
||||
try {
|
||||
const toolIdInitial = selectedTool?.id || 'chatgpt';
|
||||
const saved = JSON.parse(localStorage.getItem(`messages_${toolIdInitial}`) || '[]');
|
||||
return Array.isArray(saved) ? saved.filter(m=>m.type!=='loading') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [input, setInput] = useState('');
|
||||
const [chatModel, setChatModel] = useState('GPT-5');
|
||||
const inputRef = useRef(null);
|
||||
const chatEndRef = useRef(null);
|
||||
|
||||
// 기본 카드: ChatGPT (도구 목록에서 제거되었기 때문에 selectedTool 이 없으면 ChatGPT로 동작)
|
||||
const defaultChatTool = { id: 'chatgpt', name: 'ChatGPT', description: '일반 대화 및 모델 선택 기능을 제공합니다.' };
|
||||
const activeTool = selectedTool || defaultChatTool;
|
||||
|
||||
// 히스토리 패널용 공통 값
|
||||
const toolId = selectedTool?.id || 'chatgpt';
|
||||
const historyPanel = <ChatHistoryPanel toolId={toolId} />;
|
||||
|
||||
useEffect(() => {
|
||||
// 카드 전환 시 해당 카드의 저장된 대화 불러오기 (기본: chatgpt)
|
||||
const toolId = selectedTool?.id || 'chatgpt';
|
||||
try {
|
||||
const key = `messages_${toolId}`;
|
||||
const saved = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
setMessages(Array.isArray(saved) ? saved.filter(m=>m.type!=='loading') : []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
}, [selectedTool?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// scroll to bottom
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// 대화 변경 시 로컬스토리지에 저장 (기본: chatgpt)
|
||||
useEffect(() => {
|
||||
const toolId = selectedTool?.id || 'chatgpt';
|
||||
try { localStorage.setItem(`messages_${toolId}`, JSON.stringify(messages)); } catch {}
|
||||
}, [messages, selectedTool?.id]);
|
||||
|
||||
|
||||
// iframe 도구 처리
|
||||
const iframeTools = {
|
||||
research_qa: 'http://yongin-qa-chatbot.daewoongai.com/',
|
||||
lims_text2sql: 'http://3.38.184.255:8080/',
|
||||
};
|
||||
|
||||
if (selectedTool && iframeTools[selectedTool.id]) {
|
||||
return (
|
||||
<section className="page page-chat" style={{ flex: 1, display: 'flex', flexDirection: 'column', position:'relative' }}>
|
||||
{historyPanel}
|
||||
<iframe
|
||||
src={iframeTools[selectedTool.id]}
|
||||
className="external-iframe"
|
||||
title="external"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedTool && selectedTool.id === 'dev_chatbot') {
|
||||
return (
|
||||
<section className="page page-chat has-file-sidebar" style={{ flex: 1, position: 'relative', display:'flex', justifyContent:'flex-start', alignItems:'stretch' }}>
|
||||
{historyPanel}
|
||||
<FileSidebar />
|
||||
{/* DevChatInput renders header, messages, input */}
|
||||
<DevChatInput />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// 문서번역 도구: Word 파일 업로드/목록/다운로드/삭제 + 실시간 텍스트 번역 지원
|
||||
if (selectedTool && selectedTool.id === 'doc_translation') {
|
||||
return (
|
||||
<section className="page page-chat has-file-sidebar" style={{ flex: 1, position: 'relative', display:'flex', justifyContent:'flex-start', alignItems:'stretch' }}>
|
||||
{historyPanel}
|
||||
<DocTranslationSidebar />
|
||||
<DocTranslationChat />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim()) return;
|
||||
const userMsg = { type: 'user', content: input.trim(), time: new Date().toLocaleTimeString() };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = '32px';
|
||||
}
|
||||
|
||||
// loading placeholder
|
||||
const loadingMsg = { type: 'loading', start: Date.now() };
|
||||
setMessages((prev) => [...prev, loadingMsg]);
|
||||
|
||||
// interval to update elapsed seconds
|
||||
const loadingInterval = setInterval(() => {
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.type === 'loading') return [...prev]; // trigger rerender
|
||||
clearInterval(loadingInterval);
|
||||
return prev;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// backend 호출
|
||||
try {
|
||||
let botContent = '';
|
||||
if (activeTool.id === 'chatgpt') {
|
||||
const modelMap = {
|
||||
'GPT-5': 'gpt-5',
|
||||
'GPT-5-mini': 'gpt-5-mini',
|
||||
'GPT-5-nano': 'gpt-5-nano',
|
||||
'GPT-4o': 'gpt-4o',
|
||||
'GPT-4.1-mini': 'gpt-4o-mini',
|
||||
'o4-mini': 'o4-mini',
|
||||
};
|
||||
const model = modelMap[chatModel] || modelMap['GPT-5'];
|
||||
const sysPrompt = `당신은 한국어로 답하는 뛰어난 AI 어시스턴트입니다.
|
||||
|
||||
✅ 반드시 사실(팩트)에 기반하여 거짓 정보 없이 답하십시오.
|
||||
✅ 먼저 내부적으로 생각(Chain-of-Thought)을 수행한 뒤, 이용자에게는 정리된 결과만 보여 주세요.
|
||||
✅ 충분한 길이(최소 5문단)로, 필요 시 제목·소제목·순서/불릿 목록·코드 블록을 포함한 **Markdown** 형식을 사용하십시오.
|
||||
✅ 이해를 돕기 위해 😃, 📌, 💡 등 적절한 이모지를 활용하십시오.
|
||||
✅ 요약만 제시하지 말고, 예시·배경 설명·추가 정보를 상세히 제공합니다.
|
||||
✅ 가능하다면 신뢰할 수 있는 공개 출처 URL 또는 참고문헌을 맨 아래에 제시하십시오.`;
|
||||
// 이미지 생성 의도 간단 감지 (예: 이미지/그려줘/생성해줘/그림/사진 등 키워드)
|
||||
// 1차: 키워드 룰기반, 2차: 모델 분류기로 보강 (샘플코드 방식)
|
||||
let wantsImage = /\b(이미지|그려줘|그림|사진|image|draw|generate\s+image|그려|create\s+an?\s+image|render)\b/i.test(userMsg.content);
|
||||
if (!wantsImage) {
|
||||
try {
|
||||
const cls = await classifyRequest(userMsg.content, 'gpt-5');
|
||||
wantsImage = cls === 'image';
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
if (wantsImage) {
|
||||
try {
|
||||
const dataUrl = await fetchOpenAIImage(userMsg.content, 'gpt-image-1', { size: '1024x1024' });
|
||||
// 이미지 응답은 전용 타입으로 렌더링
|
||||
clearInterval(loadingInterval);
|
||||
const botMsg = { type: 'image', content: dataUrl, time: new Date().toLocaleTimeString() };
|
||||
setMessages((prev) => {
|
||||
const copy = [...prev];
|
||||
const idx = copy.findIndex((m) => m.type === 'loading');
|
||||
if (idx !== -1) copy[idx] = botMsg; else copy.push(botMsg);
|
||||
return copy;
|
||||
});
|
||||
return; // 조기 반환 (텍스트 처리 불필요)
|
||||
} catch (err) {
|
||||
// 이미지 생성 실패 시 텍스트 답변으로 폴백
|
||||
botContent = await fetchOpenAIChat([
|
||||
{ role: 'system', content: sysPrompt },
|
||||
{ role: 'user', content: userMsg.content },
|
||||
], model, { });
|
||||
}
|
||||
} else {
|
||||
botContent = await fetchOpenAIChat([
|
||||
{ role: 'system', content: sysPrompt },
|
||||
{ role: 'user', content: userMsg.content },
|
||||
], model, { });
|
||||
}
|
||||
} else {
|
||||
const formData = new FormData();
|
||||
formData.append('message', userMsg.content);
|
||||
formData.append('tool_id', activeTool.id);
|
||||
const res = await fetch('http://localhost:8010/chat', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
botContent = data.response;
|
||||
}
|
||||
clearInterval(loadingInterval);
|
||||
const botMsg = { type: 'bot', content: botContent, time: new Date().toLocaleTimeString(), markdown:true };
|
||||
// replace loading placeholder
|
||||
setMessages((prev) => {
|
||||
const copy = [...prev];
|
||||
const idx = copy.findIndex((m) => m.type === 'loading');
|
||||
if (idx !== -1) copy[idx] = botMsg;
|
||||
return copy;
|
||||
});
|
||||
} catch (e) {
|
||||
setMessages((prev) => [...prev, { type: 'bot', content: '서버 오류', time: new Date().toLocaleTimeString() }]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page-chat" style={{ flex: 1, display:'flex', flexDirection:'column', position:'relative' }}>
|
||||
{historyPanel}
|
||||
<div className="chat-center" style={{ width: '100%', maxWidth: 800 }}>
|
||||
<div className="tool-chat-header">
|
||||
<div className="tool-chat-title">{activeTool.name}</div>
|
||||
<div className="tool-chat-desc">{activeTool.description}</div>
|
||||
</div>
|
||||
{messages.map((m, idx) => (
|
||||
<div key={idx} className={`chat-message ${
|
||||
m.type === 'user' ? 'user-message' : (
|
||||
m.type === 'bot' || m.type === 'image' ? 'bot-message' : 'bot-message progress'
|
||||
)}`}>
|
||||
{m.type==='loading' ? (
|
||||
<div className="message-content" style={{color:'#888',fontStyle:'italic'}}>생각중...</div>
|
||||
) : m.type==='image' ? (
|
||||
<div className="message-content"><img src={m.content} alt="generated" style={{ maxWidth:'100%', borderRadius:12 }}/></div>
|
||||
) : m.markdown ? (
|
||||
<div className="message-content" dangerouslySetInnerHTML={{ __html: marked.parse(m.content) }} />
|
||||
) : (
|
||||
<div className="message-content">{m.content}</div>
|
||||
)}
|
||||
<div className="message-time">{ m.type==='loading' ? `${Math.floor((Date.now()-m.start)/1000)}s` : m.time}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-area">
|
||||
{activeTool.id==='chatgpt' && (
|
||||
<select value={chatModel} onChange={(e)=>setChatModel(e.target.value)} style={{ marginRight:12, height:36, borderRadius:12, border:'1px solid #ffd6c2', color:'#ff6b4a' }}>
|
||||
<option value="GPT-5">GPT-5</option>
|
||||
<option value="GPT-5-mini">GPT-5-mini</option>
|
||||
<option value="GPT-5-nano">GPT-5-nano</option>
|
||||
<option value="GPT-4o">GPT-4o</option>
|
||||
<option value="GPT-4.1-mini">GPT-4.1-mini</option>
|
||||
<option value="o4-mini">o4-mini</option>
|
||||
</select>
|
||||
)}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="chat-input"
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onInput={(e)=>{ e.target.style.height='auto'; e.target.style.height=Math.min(e.target.scrollHeight,240)+"px"; }}
|
||||
placeholder="메시지 입력"
|
||||
style={{ flex: 1, resize: 'none', border: 'none', outline: 'none', overflowY:'auto' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="chat-send" onClick={sendMessage} disabled={!input.trim()}>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DocTranslationSidebar() {
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
const extractRealFilename = (filename) => {
|
||||
const regex = /^\[doc_translation\]_admin_\d{14,17}_(.+)$/;
|
||||
const match = filename.match(regex);
|
||||
return match ? match[1] : filename;
|
||||
};
|
||||
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8010/doc_translation/files');
|
||||
const data = await res.json();
|
||||
setFiles(Array.isArray(data) ? data : []);
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
useEffect(() => { loadFiles(); }, []);
|
||||
|
||||
const handleUpload = async () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.doc,.docx';
|
||||
input.multiple = true;
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
const files = Array.from(input.files || []);
|
||||
if (!files.length) return;
|
||||
const formData = new FormData();
|
||||
files.forEach((f) => formData.append('files', f));
|
||||
try {
|
||||
await fetch('http://localhost:8010/doc_translation/upload_doc', { method: 'POST', body: formData });
|
||||
} finally {
|
||||
document.body.removeChild(input);
|
||||
loadFiles();
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleDelete = async (serverFilename) => {
|
||||
if (!window.confirm(`${extractRealFilename(serverFilename)} 파일을 삭제하시겠습니까?`)) return;
|
||||
await fetch(`http://localhost:8010/doc_translation/files/${encodeURIComponent(serverFilename)}`, { method: 'DELETE' });
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
const handleDownload = async (filename) => {
|
||||
const res = await fetch(`http://localhost:8010/doc_translation/download/${encodeURIComponent(filename)}`);
|
||||
if (!res.ok) return alert('다운로드 실패');
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = extractRealFilename(filename);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-sidebar">
|
||||
<div className="file-sidebar-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>파일 리스트</span>
|
||||
<button className="file-add-btn" onClick={handleUpload}>+</button>
|
||||
</div>
|
||||
<ul className="file-list">
|
||||
{files.map((f) => (
|
||||
<li key={f.filename} className={f.has_result ? 'translation-result-item' : ''} data-fname={f.filename} title={extractRealFilename(f.filename)}>
|
||||
{f.has_result ? (
|
||||
<div className="file-item-content">
|
||||
<div className="original-file">{extractRealFilename(f.filename)}</div>
|
||||
<div className="file-actions">
|
||||
<a href="#" className="download-original-link" onClick={(e)=>{e.preventDefault(); handleDownload(f.filename);}}>[원본다운]</a>
|
||||
<a href="#" className="download-result-link" onClick={(e)=>{e.preventDefault(); handleDownload(f.result_filename);}}>[결과다운]</a>
|
||||
<button className="file-del-btn" onClick={()=>handleDelete(f.filename)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="translating">번역중...</span>
|
||||
<button className="file-del-btn" onClick={()=>handleDelete(f.filename)}>✕</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocTranslationChat() {
|
||||
const { selectedTool } = useTool();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [model, setModel] = useState('internal'); // internal | external
|
||||
const inputRef2 = useRef(null);
|
||||
const chatEndRef = useRef(null);
|
||||
|
||||
// 카드 전환 시 저장된 대화 불러오기
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('messages_doc_translation') || '[]');
|
||||
setMessages(Array.isArray(saved) ? saved : []);
|
||||
} catch { setMessages([]); }
|
||||
}, [selectedTool?.id]);
|
||||
useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
// 변경 시 저장
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('messages_doc_translation', JSON.stringify(messages)); } catch {}
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim()) return;
|
||||
const userMsg = { type: 'user', content: input.trim(), time: new Date().toLocaleTimeString() };
|
||||
setMessages((prev) => [...prev, userMsg, { type: 'loading', start: Date.now() }]);
|
||||
setInput('');
|
||||
if (inputRef2.current) inputRef2.current.style.height = '32px';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('message', userMsg.content);
|
||||
// ChatPage 공통 /chat 엔드포인트의 문서번역 분기와 동일 동작을 위해 model 전달
|
||||
form.append('tool_id', 'doc_translation');
|
||||
form.append('model', model === 'internal' ? 'internal' : 'gpt-4o');
|
||||
const res = await fetch('http://localhost:8010/chat', { method: 'POST', body: form });
|
||||
const data = await res.json();
|
||||
const botContent = data.response;
|
||||
setMessages((prev) => {
|
||||
const copy = [...prev];
|
||||
const idx = copy.findIndex((m) => m.type === 'loading');
|
||||
const botMsg = { type: 'bot', content: botContent, time: new Date().toLocaleTimeString(), markdown: true };
|
||||
if (idx !== -1) copy[idx] = botMsg; else copy.push(botMsg);
|
||||
return copy;
|
||||
});
|
||||
} catch (e) {
|
||||
setMessages((prev) => [...prev, { type: 'bot', content: '서버 오류', time: new Date().toLocaleTimeString() }]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-center" style={{ width: '100%', maxWidth: 800 }}>
|
||||
<div className="tool-chat-header">
|
||||
<div className="tool-chat-title">문서번역</div>
|
||||
<div className="tool-chat-desc">MS Word 문서를 업로드하면 한글을 영어로 번역합니다.</div>
|
||||
</div>
|
||||
{messages.map((m, idx) => (
|
||||
<div key={idx} className={`chat-message ${m.type === 'user' ? 'user-message' : (m.type==='bot' ? 'bot-message' : 'bot-message progress')}`}>
|
||||
{m.type==='loading' ? (
|
||||
<div className="message-content" style={{color:'#888',fontStyle:'italic'}}>생각중...</div>
|
||||
) : m.markdown ? (
|
||||
<div className="message-content" dangerouslySetInnerHTML={{ __html: marked.parse(m.content) }} />
|
||||
) : (
|
||||
<div className="message-content">{m.content}</div>
|
||||
)}
|
||||
<div className="message-time">{ m.type==='loading' ? `${Math.floor((Date.now()-m.start)/1000)}s` : m.time}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chat-input-wrapper">
|
||||
<div className="chat-input-area">
|
||||
<select value={model} onChange={(e)=>setModel(e.target.value)} style={{ marginRight:12, height:36, borderRadius:12, border:'1px solid #ffd6c2', color:'#ff6b4a' }}>
|
||||
<option value="internal">자체모델</option>
|
||||
<option value="external">외부모델</option>
|
||||
</select>
|
||||
<textarea
|
||||
ref={inputRef2}
|
||||
className="chat-input"
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onInput={(e)=>{ e.target.style.height='auto'; e.target.style.height=Math.min(e.target.scrollHeight,240)+"px"; }}
|
||||
placeholder="번역할 한국어 텍스트를 입력하세요"
|
||||
style={{ flex: 1, resize: 'none', border: 'none', outline: 'none', overflowY:'auto' }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="chat-send" onClick={sendMessage} disabled={!input.trim()}>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
src/pages/LecturePage.jsx
Normal file
79
src/pages/LecturePage.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
title: '강수진박사의 프롬프트 엔지니어링의 세계',
|
||||
videos: [
|
||||
'https://www.youtube.com/watch?v=GlvOHXJT_gI&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=14&t=26s',
|
||||
'https://www.youtube.com/watch?v=nl34M5bKkVM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=12',
|
||||
'https://www.youtube.com/watch?v=0sRlrW_UyLk&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=11',
|
||||
'https://www.youtube.com/watch?v=MtTAprzHOBg&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=10',
|
||||
'https://www.youtube.com/watch?v=CyWcZWVwCjQ&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=9',
|
||||
'https://www.youtube.com/watch?v=X7ycln4JREM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=8',
|
||||
'https://www.youtube.com/watch?v=rrCmsOFt2UU&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=7',
|
||||
'https://www.youtube.com/watch?v=7cyDMjzcdxM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=6',
|
||||
'https://www.youtube.com/watch?v=Sr4MEivnt4M&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=5',
|
||||
'https://www.youtube.com/watch?v=F4ExQ3P_A5w&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=4',
|
||||
'https://www.youtube.com/watch?v=tiC5k9P93cE&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=3',
|
||||
'https://www.youtube.com/watch?v=nfPXfsVz6jM&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=2',
|
||||
'https://www.youtube.com/watch?v=9NH3FhBX2ng&list=PL7d4-rFjtYdK4-RGBJTXbgLI5a-H7lz7g&index=1',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'AI, 안해보면 모른다',
|
||||
videos: [
|
||||
'https://www.youtube.com/watch?v=menXWx89QFg&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=14',
|
||||
'https://www.youtube.com/watch?v=6IsYJy3ussQ&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=13',
|
||||
'https://www.youtube.com/watch?v=Mpk4LNZ_P4c&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=12',
|
||||
'https://www.youtube.com/watch?v=r2UMvkwJTRc&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=11',
|
||||
'https://www.youtube.com/watch?v=4uc58O4poJE&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=10',
|
||||
'https://www.youtube.com/watch?v=njAu-W-JA6c&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=9',
|
||||
'https://www.youtube.com/watch?v=SaYxoKVYgsk&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=8',
|
||||
'https://www.youtube.com/watch?v=sDDW5w2jD2Q&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=7',
|
||||
'https://www.youtube.com/watch?v=Ext47QeBCbY&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=6',
|
||||
'https://www.youtube.com/watch?v=og0mGD29Hw8&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=5',
|
||||
'https://www.youtube.com/watch?v=GkZgTth3guQ&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=4',
|
||||
'https://www.youtube.com/watch?v=oSZBGZmaTTw&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=3',
|
||||
'https://www.youtube.com/watch?v=t6Xc1Ey5PMY&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=2',
|
||||
'https://www.youtube.com/watch?v=aFw4Z9F4Ens&list=PL7d4-rFjtYdK5d2KOXyyaBymJYtEDRmy3&index=1',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'MCP를 배워보자',
|
||||
videos: [
|
||||
'https://www.youtube.com/watch?v=kEAV-PqWD_4',
|
||||
'https://www.youtube.com/watch?v=ha9kn0qe4Mc',
|
||||
'https://www.youtube.com/watch?v=ISrYHGg2C2c',
|
||||
'https://www.youtube.com/watch?v=cxOoV2guNQQ',
|
||||
'https://www.youtube.com/watch?v=oAxunD8k0C8',
|
||||
'https://www.youtube.com/watch?v=oZ1O6Z9HwW0',
|
||||
'https://www.youtube.com/watch?v=nyZnrKVaIXU',
|
||||
'https://www.youtube.com/watch?v=BzwhskWZ-CQ',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function toThumb(url) {
|
||||
const match = url.match(/(?:v=|\.be\/|embed\/)([\w-]{11})/);
|
||||
const id = match ? match[1] : '';
|
||||
return id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : '';
|
||||
}
|
||||
|
||||
export default function LecturePage() {
|
||||
return (
|
||||
<section className="page page-lecture" style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '24px' }}>
|
||||
{SECTIONS.map((sec) => (
|
||||
<React.Fragment key={sec.title}>
|
||||
<h3 className="lecture-topic" style={{ marginTop: sec.title===SECTIONS[0].title ? 0 : 24 }}>{sec.title}</h3>
|
||||
<div className="lecture-video-list">
|
||||
{sec.videos.map((url) => (
|
||||
<a key={url} href={url} target="_blank" rel="noreferrer" className="lecture-video">
|
||||
<img src={toThumb(url)} alt="video thumbnail" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
39
src/pages/LoginPage.jsx
Normal file
39
src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function LoginPage({ onLoggedIn }) {
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
onLoggedIn?.();
|
||||
} catch (err) {
|
||||
setError('이메일 또는 비밀번호가 올바르지 않습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page" style={{ flex: 1, display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||
<form onSubmit={handleSubmit} style={{ width:'100%', maxWidth:480, padding:'0 16px' }}>
|
||||
<div style={{ marginBottom:16, background:'#f7f7f7', borderRadius:12, padding:'16px 18px' }}>
|
||||
<input type="email" placeholder="이메일" value={email} onChange={(e)=>setEmail(e.target.value)}
|
||||
style={{ width:'100%', background:'transparent', border:'none', outline:'none', fontSize:'1.1rem' }} required />
|
||||
</div>
|
||||
<div style={{ marginBottom:24, background:'#f7f7f7', borderRadius:12, padding:'16px 18px' }}>
|
||||
<input type="password" placeholder="비밀번호" value={password} onChange={(e)=>setPassword(e.target.value)}
|
||||
style={{ width:'100%', background:'transparent', border:'none', outline:'none', fontSize:'1.1rem' }} required />
|
||||
</div>
|
||||
{error && <div style={{ color:'#e53935', marginBottom:12, textAlign:'center' }}>{error}</div>}
|
||||
<button type="submit" style={{ width:'100%', height:64, borderRadius:18, border:'none', background:'#111', color:'#fff', fontSize:'1.4rem', fontWeight:700, cursor:'pointer' }}>로그인</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
107
src/pages/QnaPage.jsx
Normal file
107
src/pages/QnaPage.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
async function fetchQnaList() {
|
||||
const res = await fetch('http://localhost:8010/community/qna');
|
||||
if (!res.ok) throw new Error('QNA 목록 조회 실패');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export default function QnaPage() {
|
||||
const { user } = useAuth();
|
||||
const [qna, setQna] = useState([]);
|
||||
const [openId, setOpenId] = useState(null);
|
||||
const [openContent, setOpenContent] = useState('');
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchQnaList().then(setQna).catch(() => setQna([]));
|
||||
}, []);
|
||||
|
||||
const handleOpen = async (id) => {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8010/community/qna/${id}?increase=1`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setOpenId(id);
|
||||
setOpenContent(data.content);
|
||||
fetchQnaList().then(setQna).catch(() => {});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const submitQna = async () => {
|
||||
if (!title.trim() || !content.trim()) return;
|
||||
const res = await fetch('http://localhost:8010/community/qna', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, content, author_id: String(user?.user_id || ''), author_email: user?.email || null }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setShowEditor(false);
|
||||
fetchQnaList().then(setQna).catch(() => {});
|
||||
} else {
|
||||
const errText = await res.text().catch(() => '');
|
||||
alert('등록 실패: ' + errText);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page-qna" style={{ flex: 1, display: 'block', paddingTop: '24px' }}>
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', margin: '0 20px 12px 20px' }}>
|
||||
<button onClick={() => setShowEditor(true)} style={{ background: '#fff8ef', color: '#ff9800', border: '1px solid #FFE0B2', padding: '8px 12px', borderRadius: 8, cursor: 'pointer', marginRight: 40 }}>글쓰기</button>
|
||||
</div>
|
||||
<table className="qna-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 60 }}>번호</th>
|
||||
<th>제목</th>
|
||||
<th style={{ width: 120 }}>등록일</th>
|
||||
<th style={{ width: 80 }}>조회수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{qna.map((r) => (
|
||||
<React.Fragment key={r.id}>
|
||||
<tr>
|
||||
<td>{r.id}</td>
|
||||
<td className="qna-title">
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); handleOpen(r.id); }}>{r.title}</a>
|
||||
</td>
|
||||
<td>{new Date(r.created_at).toISOString().slice(0, 10)}</td>
|
||||
<td>{r.views}</td>
|
||||
</tr>
|
||||
{openId === r.id && (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<div style={{ background: '#fff8ef', border: '1px solid #FFE0B2', borderRadius: 12, padding: 16 }}>
|
||||
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{openContent}</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
|
||||
<button onClick={() => setOpenId(null)} style={{ background: '#fff', border: '1px solid #ddd', padding: '6px 10px', borderRadius: 8 }}>닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{showEditor && (
|
||||
<div style={{ width: 'calc(100% - 80px)', margin: '16px 20px 0 20px', background: '#fff', border: '1px solid #ffd6c2', borderRadius: 12, padding: 16 }}>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="제목" style={{ width: 'calc(100% - 40px)', marginBottom: 8, padding: '10px 12px', borderRadius: 8, border: '1px solid #ffd6c2' }} />
|
||||
<textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="내용" rows={6} style={{ width: 'calc(100% - 40px)', padding: '10px 12px', borderRadius: 8, border: '1px solid #ffd6c2' }} />
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
||||
<button onClick={() => setShowEditor(false)} style={{ background: '#fff', border: '1px solid #ddd', padding: '8px 12px', borderRadius: 8 }}>취소</button>
|
||||
<button onClick={submitQna} style={{ background: '#ff9800', color: '#fff', border: 'none', padding: '8px 12px', borderRadius: 8 }}>등록</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
63
src/pages/ToolsPage.jsx
Normal file
63
src/pages/ToolsPage.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTool } from '../context/ToolContext';
|
||||
|
||||
export default function ToolsPage({ onNavigate }) {
|
||||
const { tools, favorites, toggleFavorite, setSelectedTool } = useTool();
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
const filtered = tools.filter((tool) => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'favorite') return favorites.includes(tool.id);
|
||||
return tool.category?.includes(filter);
|
||||
});
|
||||
|
||||
const handleSelect = (tool) => {
|
||||
setSelectedTool(tool);
|
||||
onNavigate && onNavigate('chat');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page-tools" style={{ flex: 1, display:'flex', flexDirection:'column' }}>
|
||||
<div className="tools-header">
|
||||
<span className="tools-title">도구 목록</span>
|
||||
<div className="tools-filters">
|
||||
{['all', 'favorite', '오픈AI', '내부AI'].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
className={`filter-btn ${filter === f ? 'active' : ''}`}
|
||||
onClick={() => setFilter(f)}
|
||||
data-filter={f}
|
||||
>
|
||||
{f === 'all' ? '전체' : f === 'favorite' ? '즐겨찾기' : f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tools-list">
|
||||
{filtered.map((tool) => {
|
||||
const fav = favorites.includes(tool.id);
|
||||
return (
|
||||
<div
|
||||
className="tool-card"
|
||||
key={tool.id}
|
||||
data-tool-id={tool.id}
|
||||
onClick={() => handleSelect(tool)}
|
||||
>
|
||||
<div className="tool-title">{tool.name}</div>
|
||||
<div className="tool-desc">{tool.description}</div>
|
||||
<div
|
||||
className={`tool-fav ${fav ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavorite(tool.id);
|
||||
}}
|
||||
>
|
||||
★
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user