/** * @license * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useEffect, createContext, useContext, useCallback } from "react"; import { motion, AnimatePresence } from "motion/react"; import { Search, Globe, Cpu, ChevronDown, Plus, HelpCircle, Loader2, ExternalLink, Filter, Download, Share2, X, ChevronLeft, ChevronRight, Sparkles, Maximize2, Minimize2, LogOut, User, Lock, AlertCircle, } from "lucide-react"; import { SearchResult, SearchMeta, ViewState, SearchSession, AuthUser } from "./types"; // ───────────────────────────────────────────────────────────── // Auth Context — 전역 인증 상태 // ───────────────────────────────────────────────────────────── interface AuthContextType { user: AuthUser | null; isLoading: boolean; login: (username: string, password: string) => Promise; logout: () => Promise; } const AuthContext = createContext({ user: null, isLoading: true, login: async () => {}, logout: async () => {}, }); const useAuth = () => useContext(AuthContext); const AUTH_STORAGE_KEY = "snap_user"; function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); // 첫 마운트 시 sessionStorage에서 사용자 정보 복원 useEffect(() => { try { const stored = sessionStorage.getItem(AUTH_STORAGE_KEY); if (stored) { setUser(JSON.parse(stored)); } } catch { sessionStorage.removeItem(AUTH_STORAGE_KEY); } setIsLoading(false); }, []); const login = useCallback(async (username: string, password: string) => { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.detail || "로그인에 실패했습니다"); } // 인증 서버 응답에서 사용자 정보 저장 const authUser: AuthUser = { name: data.name || "", username: data.username || "", department: data.department || "", company: data.company || "", email: data.email || "", section: data.section || "", title: data.title || "", }; sessionStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(authUser)); setUser(authUser); }, []); const logout = useCallback(async () => { try { await fetch("/api/auth/logout", { method: "POST" }); } catch { // 서버 호출 실패해도 로컬은 정리 } sessionStorage.removeItem(AUTH_STORAGE_KEY); setUser(null); }, []); return ( {children} ); } // ───────────────────────────────────────────────────────────── // LoginScreen — 전체화면 로그인 (게이팅) // ───────────────────────────────────────────────────────────── function LoginScreen() { const { login } = useAuth(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!username.trim() || !password.trim()) { setError("사번과 비밀번호를 입력하세요"); return; } setError(""); setIsSubmitting(true); try { await login(username.trim(), password); } catch (err: any) { setError(err.message || "로그인에 실패했습니다"); } finally { setIsSubmitting(false); } }; return (
{/* 로고 */}

SNAP

SK실트론 사내 지침서 검색 시스템

{/* 로그인 폼 */}
{/* 사번 */}
setUsername(e.target.value)} placeholder="사번을 입력하세요" autoComplete="username" autoFocus className="w-full pl-10 pr-4 py-3 bg-surface-container-low border border-outline-variant/50 rounded-md text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:border-[#E1251B] focus:ring-1 focus:ring-[#E1251B]/30 transition-all text-sm" />
{/* 비밀번호 */}
setPassword(e.target.value)} placeholder="비밀번호를 입력하세요" autoComplete="current-password" className="w-full pl-10 pr-4 py-3 bg-surface-container-low border border-outline-variant/50 rounded-md text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:border-[#E1251B] focus:ring-1 focus:ring-[#E1251B]/30 transition-all text-sm" />
{/* 에러 메시지 */} {error && ( {error} )} {/* 로그인 버튼 */}

사내 AD 계정(사번)으로 로그인합니다

© SK siltron · Generative AI Team

); } // ───────────────────────────────────────────────────────────── // UserMenu — Navbar 우측 사용자 메뉴 // ───────────────────────────────────────────────────────────── function UserMenu() { const { user, logout } = useAuth(); const [open, setOpen] = useState(false); if (!user) return null; return (
{open && ( <> {/* 클릭 외부 닫기용 오버레이 */}
setOpen(false)} /> {/* 사용자 정보 */}

{user.name}

{user.department} · {user.title}

{user.email}

{/* 로그아웃 */}
)}
); } // ───────────────────────────────────────────────────────────── // 링크 추출 헬퍼 — attach_link/doc_page_link 매핑 단일화 // ───────────────────────────────────────────────────────────── // 백엔드 매핑: // - result.url ← attach_link (다운로드) // - result.docPageLink ← doc_page_link (QMS 시스템 링크) function getDownloadUrl(result: SearchResult): string | null { const url = (result as any).url; if (!url || url === "#") return null; return url; } function getSystemLinkUrl(result: SearchResult): string | null { const link = (result as any).docPageLink; if (!link) return null; return link; } function openExternal(url: string | null) { if (!url) return; window.open(url, "_blank", "noopener,noreferrer"); } // ───────────────────────────────────────────────────────────── // snap_data 경로 헬퍼 // 검색된 페이지의 display_rel_path를 템플릿으로 임의 페이지 URL 생성 // 파일명 규칙: __<page>.jpg (패딩 없는 정수) // 예: ".../<doc>_<title>_3.jpg" → ".../<doc>_<title>_7.jpg" // ───────────────────────────────────────────────────────────── function buildPageThumbUrl( templateRelPath: string | null | undefined, targetPage: number, ): string | null { if (!templateRelPath) return null; const replaced = templateRelPath.replace( /_(\d+)\.(jpg|jpeg|png)$/i, `_${targetPage}.$2`, ); if (replaced === templateRelPath) return null; // 패턴 매칭 실패 const encoded = replaced.split("/").map(encodeURIComponent).join("/"); return `/snap_data/${encoded}`; } // ───────────────────────────────────────────────────────────── // Navbar // ───────────────────────────────────────────────────────────── const Navbar = ({ onHome }: { onHome: () => void }) => ( <header className="glass-panel sticky top-0 z-50 px-6 py-3 flex justify-between items-center transition-all duration-300"> <div className="flex items-center gap-8"> <h1 onClick={onHome} className="text-[#E1251B] font-headline font-black text-2xl tracking-tighter cursor-pointer hover:opacity-80 transition-opacity" > SNAP </h1> <nav className="hidden md:flex items-center gap-6"> <a href="https://soni.sksiltron.co.kr/" target="_blank" rel="noopener noreferrer" className="text-on-surface hover:text-[#E1251B] transition-colors font-headline text-sm font-bold tracking-wide px-3 py-2 rounded-sm hover:bg-surface-container-high/30" > SONI </a> <a href="https://mail.sksiltron.co.kr/Covi/QMSN/default.asp" target="_blank" rel="noopener noreferrer" className="text-on-surface hover:text-[#E1251B] transition-colors font-headline text-sm font-bold tracking-wide px-3 py-2 rounded-sm hover:bg-surface-container-high/30" > QMS </a> </nav> </div> <UserMenu /> </header> ); // ───────────────────────────────────────────────────────────── // 필터 옵션 (실데이터 doc_cate 분포 기반) // ───────────────────────────────────────────────────────────── const REGION_OPTIONS = [ "전체", "3공장", "전사공통", "2공장", "2공장SiC", "공장공통", "1공장", "청주공장", "서울", ]; const PRODUCT_OPTIONS = [ "전체", "300mm", "지원부문", "제품공통", "200mm", "150mm", ]; const FilterCard = ({ icon: Icon, title, options, value, onChange, }: { icon: any; title: string; options: string[]; value: string; onChange: (v: string) => void; }) => ( <div className="bg-surface-container-low rounded-xl p-6 transition-all duration-300 hover:shadow-ambient"> <div className="flex items-center mb-5 pb-3"> <Icon className="text-[#8B1A1A] mr-2" size={20} /> <h3 className="font-headline font-semibold text-on-surface text-[14px]"> {title} </h3> </div> <div className="relative"> <select value={value} onChange={(e) => onChange(e.target.value)} className="w-full appearance-none bg-white border border-outline-variant text-on-surface font-sans text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/10 focus:border-primary cursor-pointer transition-all" > {options.map((opt) => ( <option key={opt} value={opt}> {opt} </option> ))} </select> <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-tertiary"> <ChevronDown size={14} /> </div> </div> </div> ); // ───────────────────────────────────────────────────────────── // ResultCard // - 시스템 링크: doc_page_link → result.docPageLink // - 다운로드: attach_link → result.url // ───────────────────────────────────────────────────────────── interface ResultCardProps { key?: string | number; result: SearchResult; onSelect: (result: SearchResult) => void; onDownloadLog?: (result: SearchResult, downloadUrl: string) => void; } const ResultCard = ({ result, onSelect, onDownloadLog }: ResultCardProps) => { const downloadUrl = getDownloadUrl(result); const systemUrl = getSystemLinkUrl(result); const handleDownload = (e: React.MouseEvent) => { e.stopPropagation(); if (!downloadUrl) { console.warn("다운로드 링크 없음", result.id); return; } onDownloadLog?.(result, downloadUrl); openExternal(downloadUrl); }; const handleSystemLink = (e: React.MouseEvent) => { e.stopPropagation(); if (!systemUrl) { console.warn("시스템 링크 없음", result.id); return; } openExternal(systemUrl); }; const handleImgError = (e: React.SyntheticEvent<HTMLImageElement>) => { e.currentTarget.src = "data:image/svg+xml;utf8," + encodeURIComponent( `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><rect fill="#eeeeee" width="400" height="300"/><text x="200" y="150" font-size="14" fill="#999" text-anchor="middle" font-family="sans-serif">No Preview</text></svg>`, ); }; return ( <motion.div layout initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} onClick={() => onSelect(result)} className="bg-surface-container-lowest rounded-sm overflow-hidden group hover:shadow-ambient transition-all duration-500 border border-outline-variant/30 cursor-pointer" > <div className="relative aspect-[4/3] overflow-hidden bg-surface-container-low"> <img src={result.thumbnail} alt={result.title} referrerPolicy="no-referrer" onError={handleImgError} className="w-full h-full object-contain transition-transform duration-700 group-hover:scale-[1.02]" /> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4"> <button onClick={handleDownload} disabled={!downloadUrl} className="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white hover:bg-white hover:text-primary transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-white/20 disabled:hover:text-white" title={downloadUrl ? "원본 문서 다운로드" : "다운로드 링크 없음"} > <Download size={16} /> </button> <button onClick={handleSystemLink} disabled={!systemUrl} className="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white hover:bg-white hover:text-primary transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-white/20 disabled:hover:text-white" title={systemUrl ? "QMS 시스템에서 열기" : "시스템 링크 없음"} > <ExternalLink size={16} /> </button> </div> {result.relevance > 90 && ( <div className="absolute top-2 left-2 bg-primary px-2 py-0.5 text-[9px] font-bold text-white uppercase tracking-tighter shadow-lg"> Top Insight </div> )} </div> <div className="p-2"> <div className="flex justify-between items-center gap-2 mb-1.5"> <h3 className="font-headline font-bold text-on-surface text-[13px] leading-tight group-hover:text-primary transition-colors line-clamp-1 flex-grow"> {result.title} </h3> <span className="shrink-0 bg-surface-container-high px-1 py-0.5 rounded-[3px] text-[9px] font-bold text-tertiary"> P.{(result.relatedPages?.slice(0, 3) || [result.page]).join(", ")} </span> </div> <div className="flex justify-between items-center pt-1.5 border-t border-outline-variant/10"> <div className="flex items-center gap-1.5"> <span className="text-[8px] font-bold text-tertiary uppercase"> 적합성 </span> <span className="text-[11px] font-bold text-[#B00020]"> {result.relevance}% </span> </div> <div className="flex items-center gap-1"> <button onClick={handleSystemLink} disabled={!systemUrl} className="flex items-center gap-1 px-1.5 py-0.5 bg-surface-container-high hover:bg-surface-container-highest rounded-sm border border-outline-variant/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface-container-high" title={systemUrl ? "QMS 시스템에서 열기" : "시스템 링크 없음"} > <ExternalLink size={11} className="text-on-surface-variant" /> <span className="text-[9px] font-bold text-on-surface-variant uppercase"> 시스템 링크 </span> </button> <button onClick={handleDownload} disabled={!downloadUrl} className="flex items-center gap-1 px-1.5 py-0.5 bg-[#E1251B] hover:bg-[#B00020] rounded-sm transition-colors shadow-sm disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[#E1251B]" title={downloadUrl ? "원본 문서 다운로드" : "다운로드 링크 없음"} > <Download size={11} className="text-white" /> <span className="text-[9px] font-bold text-white uppercase"> 다운로드 </span> </button> </div> </div> </div> </motion.div> ); }; // ───────────────────────────────────────────────────────────── // DetailModal // - /api/doc-detail로 LLM 종합 답변 표시 // - 페이지 네비게이션: 1~totalPages 전체 순회 (snap_data 직접 접근) // - 초기 페이지: 가장 적합한 result.page // - RELATED PAGES: 검색에 매칭된 페이지 빠른 점프 // ───────────────────────────────────────────────────────────── const DetailModal = ({ result, query, onClose, onDownloadLog, }: { result: SearchResult; query: string; onClose: () => void; onDownloadLog?: (result: SearchResult, downloadUrl: string) => void; }) => { const [currentPage, setCurrentPage] = useState<number>(result.page); const [isExpanded, setIsExpanded] = useState(false); const [imgError, setImgError] = useState(false); const [totalPages, setTotalPages] = useState<number>(0); const [vlmAnswer, setVlmAnswer] = useState<string | null>(null); const [vlmLoading, setVlmLoading] = useState(false); const [vlmError, setVlmError] = useState<string | null>(null); const relatedPages = result.relatedPages || [result.page]; const sortedMatched = [...new Set(relatedPages)].sort((a, b) => a - b); const matchedCount = result.matchedPageCount ?? sortedMatched.length; const downloadUrl = getDownloadUrl(result); const systemUrl = getSystemLinkUrl(result); const pages = (result as any).pages as | Array<{ page: number | null; thumbnail: string; content: string | null; score?: number; rerank_score?: number | null; source?: string; image_name?: string | null; display_rel_path?: string | null; }> | undefined; // 검색된 페이지 중 display_rel_path를 가진 첫 항목을 템플릿으로 사용 const templateRelPath = pages?.find((p) => p.display_rel_path)?.display_rel_path ?? null; // 1순위: 검색된 페이지에 currentPage 매칭이 있으면 그 썸네일 // 2순위: 템플릿 기반 동적 URL 생성 // 3순위: 대표 썸네일 const matched = pages?.find((p) => p.page === currentPage); const pageThumb = matched?.thumbnail || buildPageThumbUrl(templateRelPath, currentPage) || result.thumbnail; // 페이지 변경 시 이미지 에러 상태 리셋 useEffect(() => { setImgError(false); }, [currentPage]); // 문서 전체 페이지 수 조회 (snap_data 폴더 스캔) useEffect(() => { const docId = (result as any).docId || result.id; if (!docId) return; let cancelled = false; fetch(`/api/doc-pages?docId=${encodeURIComponent(docId)}`) .then((r) => r.json()) .then((data) => { if (!cancelled && typeof data?.totalPages === "number") { setTotalPages(data.totalPages); } }) .catch(() => { /* 실패 시 0 유지 — 좌우 이동 비활성화 효과 */ }); return () => { cancelled = true; }; }, [result.id]); // ESC + 화살표 키 핸들러 (모달/전체화면 공용) useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { if (isExpanded) { setIsExpanded(false); } else { onClose(); } return; } if (e.key === "ArrowLeft") { e.preventDefault(); handlePrevPage(); } else if (e.key === "ArrowRight") { e.preventDefault(); handleNextPage(); } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, [onClose, isExpanded]); // LLM 답변 호출 useEffect(() => { let cancelled = false; const docId = (result as any).docId || result.id; if (!docId || !pages || pages.length === 0) return; setVlmLoading(true); setVlmError(null); setVlmAnswer(null); fetch("/api/doc-detail", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, docId, pages: pages.map((p) => ({ page: p.page, score: p.score, rerank_score: p.rerank_score, content: p.content, source: p.source, image_name: p.image_name, display_rel_path: p.display_rel_path, })), }), }) .then(async (r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then((data) => { if (!cancelled) { setVlmAnswer(data.vlm_answer || null); setVlmLoading(false); } }) .catch((e) => { if (!cancelled) { console.error("doc-detail 호출 실패", e); setVlmError(String(e)); setVlmLoading(false); } }); return () => { cancelled = true; }; }, [result.id, query]); // 문서 전체 페이지 순회 (1↔totalPages). totalPages 미확정 시 매칭 페이지 사이만 순회. const handlePrevPage = () => { if (totalPages > 0) { setCurrentPage((prev) => (prev > 1 ? prev - 1 : totalPages)); return; } if (sortedMatched.length === 0) return; setCurrentPage((prev) => { const idx = sortedMatched.indexOf(prev); if (idx === -1) return sortedMatched[0]; return idx > 0 ? sortedMatched[idx - 1] : sortedMatched[sortedMatched.length - 1]; }); }; const handleNextPage = () => { if (totalPages > 0) { setCurrentPage((prev) => (prev < totalPages ? prev + 1 : 1)); return; } if (sortedMatched.length === 0) return; setCurrentPage((prev) => { const idx = sortedMatched.indexOf(prev); if (idx === -1) return sortedMatched[0]; return idx < sortedMatched.length - 1 ? sortedMatched[idx + 1] : sortedMatched[0]; }); }; // 검색에 매칭된 페이지인지 표시 (UI 강조용) const isMatchedPage = relatedPages.includes(currentPage); return ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-10" > <div className="absolute inset-0 bg-on-surface/40 backdrop-blur-md" onClick={onClose} /> <AnimatePresence> {isExpanded && ( <motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className="fixed inset-0 z-[150] bg-black/95 flex items-center justify-center p-2 md:p-4" onClick={() => setIsExpanded(false)} > <div className="relative w-full h-full flex items-center justify-center" onClick={(e) => e.stopPropagation()} > <img src={pageThumb} alt={result.title} referrerPolicy="no-referrer" className="w-full h-full object-contain shadow-2xl scale-[0.98]" /> <button onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }} className="absolute top-0 right-0 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors" title="축소 (ESC)" > <Minimize2 size={32} /> </button> {/* 좌측 이전 페이지 버튼 */} <button onClick={(e) => { e.stopPropagation(); handlePrevPage(); }} className="absolute left-4 top-1/2 -translate-y-1/2 p-4 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors backdrop-blur-sm" title="이전 페이지 (←)" > <ChevronLeft size={36} /> </button> {/* 우측 다음 페이지 버튼 */} <button onClick={(e) => { e.stopPropagation(); handleNextPage(); }} className="absolute right-4 top-1/2 -translate-y-1/2 p-4 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors backdrop-blur-sm" title="다음 페이지 (→)" > <ChevronRight size={36} /> </button> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/60 text-white px-6 py-2 rounded-full border border-white/10 backdrop-blur-md flex items-center gap-4"> <span className="text-sm font-bold uppercase tracking-widest"> {result.title} </span> <div className="w-px h-4 bg-white/20" /> <span className="text-sm font-bold"> Page : {currentPage} {totalPages > 0 && ` / ${totalPages}`} </span> </div> </div> </motion.div> )} </AnimatePresence> <motion.div initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} className="relative bg-surface-container-lowest w-full max-w-6xl overflow-hidden rounded-lg shadow-2xl flex flex-col lg:flex-row border border-outline-variant" > <button onClick={onClose} className="absolute top-6 right-6 z-20 p-2 rounded-full bg-black/40 text-white hover:bg-black/60 transition-colors shadow-lg" > <X size={24} /> </button> {/* 좌측: 페이지 이미지 */} <div className="w-full lg:w-[60%] flex flex-col bg-surface-container-highest group"> <div className="w-full aspect-[4/3] overflow-hidden relative bg-surface-container-low"> <img src={pageThumb} alt={`${result.title} - p.${currentPage}`} referrerPolicy="no-referrer" onError={() => setImgError(true)} className="w-full h-full object-contain" /> {imgError && ( <div className="absolute inset-0 flex flex-col items-center justify-center bg-surface-container-high text-tertiary gap-2"> <p className="text-sm font-bold"> 페이지 {currentPage} 이미지를 불러올 수 없습니다 </p> <p className="text-xs text-tertiary/70"> 존재하지 않거나 접근 불가한 페이지일 수 있습니다 </p> </div> )} <button onClick={() => setIsExpanded(true)} className="absolute top-4 right-4 px-4 py-3 rounded-sm bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-all hover:bg-[#E1251B] flex items-center gap-2.5 shadow-lg backdrop-blur-sm border border-white/10" title="전체화면 확대" > <Maximize2 size={20} /> <span className="text-[13px] font-bold">전체화면 확대</span> </button> {/* 검색 매칭 페이지 표시 */} {isMatchedPage && ( <div className="absolute top-4 left-4 bg-primary/90 text-white px-2.5 py-1 rounded-sm text-[10px] font-bold uppercase tracking-widest shadow-lg"> Matched Page </div> )} </div> <div className="bg-surface-container-highest p-4 flex items-center justify-between border-t border-outline-variant/10"> <div className="flex items-center gap-1.5 px-2"> <div className="w-48 h-1.5 bg-outline-variant/30 rounded-full overflow-hidden"> <div className="h-full bg-primary transition-all duration-300" style={{ width: `${ totalPages > 0 ? (currentPage / totalPages) * 100 : sortedMatched.length > 0 ? ((Math.max(0, sortedMatched.indexOf(currentPage)) + 1) / sortedMatched.length) * 100 : 0 }%`, }} /> </div> </div> <div className="flex items-center gap-6 bg-black/80 text-white px-6 py-2 rounded-sm border border-white/10 shadow-lg"> <button onClick={handlePrevPage} className="p-1 hover:text-primary transition-colors cursor-pointer" title="이전 매칭 페이지" > <ChevronLeft size={20} /> </button> <span className="text-[13px] font-headline font-bold uppercase tracking-widest min-w-[160px] text-center"> PAGE {currentPage} {totalPages > 0 && <> / {totalPages}</>} <span className="text-tertiary"> · 관련 {matchedCount}건</span> </span> <button onClick={handleNextPage} className="p-1 hover:text-primary transition-colors cursor-pointer" title="다음 페이지" > <ChevronRight size={20} /> </button> </div> </div> </div> {/* 우측: 콘텐츠 */} <div className="w-full lg:w-[40%] p-6 flex flex-col bg-surface-container-lowest border-l border-outline-variant/30"> <div className="mb-6 pb-4 border-b border-outline-variant/10"> <span className="text-[10px] font-bold text-tertiary uppercase tracking-widest block mb-2"> 검색어 </span> <div className="text-xl font-headline font-black text-[#E1251B] bg-[#E1251B]/5 px-4 py-3 rounded-sm border-l-4 border-[#E1251B] shadow-sm"> "{query}" </div> </div> <h2 className="text-2xl font-headline font-black text-on-surface mb-6 leading-tight tracking-tight"> {result.title} </h2> <div className="space-y-4 flex-grow flex flex-col min-h-0"> <div className="flex items-center justify-between pb-4 border-b border-outline-variant/20"> <div className="flex items-start gap-3"> <div className="w-1 bg-[#E1251B] h-8 rounded-full" /> <div> <p className="text-[10px] font-bold text-tertiary uppercase mb-0.5 tracking-wider"> 선택 페이지 </p> <p className="text-xl font-headline font-black text-on-surface"> Page : {currentPage} {totalPages > 0 && ( <span className="text-base font-bold text-tertiary"> {" / "} {totalPages} </span> )} <span className="text-xs font-normal text-tertiary ml-1"> (관련 {matchedCount}건) </span> </p> </div> </div> <div className="text-right"> <p className="text-[10px] font-bold text-tertiary uppercase mb-0.5 tracking-wider"> 관련 페이지 </p> <div className="flex flex-wrap gap-1.5 justify-end max-w-[180px]"> {relatedPages.map((p, i) => ( <button key={i} onClick={() => setCurrentPage(p)} className={`text-[10px] font-bold px-1.5 py-0.5 rounded transition-colors ${ currentPage === p ? "bg-primary text-white font-black scale-110" : "bg-surface-container-high text-tertiary hover:bg-surface-container-highest" }`} title={`페이지 ${p}로 이동 (검색 매칭)`} > {p} </button> ))} </div> </div> </div> <div className="bg-surface-container-low p-5 rounded-sm border border-outline-variant/20 shadow-sm flex-grow overflow-y-auto"> <p className="text-[10px] font-bold text-tertiary uppercase mb-3 tracking-wider flex items-center gap-2"> <Sparkles size={12} className="text-primary" /> AI 종합 답변 (LLM Synthesis) </p> {vlmLoading && ( <div className="flex items-center gap-2 text-[12px] text-tertiary py-4"> <Loader2 size={14} className="animate-spin text-primary" /> <span>문서 페이지를 종합하여 답변을 생성 중입니다...</span> </div> )} {vlmError && !vlmLoading && ( <p className="text-[12px] text-[#B00020] py-2"> 답변 생성 중 오류가 발생했습니다. 좌측 페이지 이미지를 참고하세요. </p> )} {vlmAnswer && !vlmLoading && ( <div className="text-[13px] text-on-surface leading-relaxed whitespace-pre-line"> {vlmAnswer} </div> )} {!vlmAnswer && !vlmLoading && !vlmError && ( <p className="text-[12px] text-tertiary italic"> 답변이 생성되지 않았습니다. 좌측 페이지 이미지를 참고하세요. </p> )} </div> </div> <div className="mt-6 pt-6 border-t border-outline-variant/20 flex flex-row gap-2"> <button onClick={() => openExternal(systemUrl)} disabled={!systemUrl} className="flex-grow py-2.5 bg-surface-container-high text-on-surface font-headline font-bold text-[11px] rounded-sm hover:bg-surface-container-highest transition-all flex items-center justify-center gap-2 uppercase tracking-wider border border-outline-variant/30 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-surface-container-high" title={systemUrl ? "QMS 시스템에서 열기" : "시스템 링크 없음"} > <ExternalLink size={16} /> <span>시스템 링크</span> </button> <button onClick={() => { if (downloadUrl) onDownloadLog?.(result, downloadUrl); openExternal(downloadUrl); }} disabled={!downloadUrl} className="flex-grow py-2.5 bg-[#E1251B] text-white font-headline font-bold text-[11px] rounded-sm hover:bg-[#B00020] shadow-sm transition-all flex items-center justify-center gap-2 uppercase tracking-wider disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[#E1251B]" title={downloadUrl ? "원본 문서 다운로드" : "다운로드 링크 없음"} > <Download size={16} /> <span>다운로드</span> </button> </div> </div> </motion.div> </motion.div> ); }; // ───────────────────────────────────────────────────────────── // App // ───────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────── // AppContent — 기존 App 로직 (인증 게이팅 후 렌더) // ───────────────────────────────────────────────────────────── function AppContent() { const { user } = useAuth(); const [view, setView] = useState<ViewState>("search"); const [query, setQuery] = useState(""); const [showFilters, setShowFilters] = useState(false); const [showSidebarFilters, setShowSidebarFilters] = useState(false); const [isSearching, setIsSearching] = useState(false); const [showMore, setShowMore] = useState(false); const [results, setResults] = useState<SearchResult[]>([]); const [meta, setMeta] = useState<SearchMeta | null>(null); const [sessions, setSessions] = useState<SearchSession[]>([]); const [selectedResult, setSelectedResult] = useState<SearchResult | null>( null, ); const [region, setRegion] = useState<string>("전체"); const [product, setProduct] = useState<string>("전체"); // ─── 활동 로그 전송 유틸 ─── const sendLog = useCallback( (type: "detail" | "download", payload: Record<string, string>) => { fetch(`/api/log/${type}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: user?.username || "anonymous", ...payload }), }).catch(() => {}); // fire-and-forget }, [user], ); // 카드 클릭 → 상세 모달 열기 + 로그 const handleSelectResult = useCallback( (result: SearchResult) => { setSelectedResult(result); sendLog("detail", { docId: result.docId || result.id, query, }); }, [query, sendLog], ); // 다운로드 클릭 로그 (ResultCard / DetailModal에서 호출) const handleDownloadLog = useCallback( (result: SearchResult, downloadUrl: string) => { sendLog("download", { docId: result.docId || result.id, query, downloadUrl, }); }, [query, sendLog], ); const handleSearch = async () => { if (!query.trim()) return; setIsSearching(true); setShowMore(false); if (view !== "results") { setView("results"); } try { const res = await fetch(`/api/search`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, region, product, topK: 10, username: user?.username || "anonymous", }), }); if (!res.ok) { const text = await res.text(); throw new Error(`Search failed [${res.status}]: ${text}`); } const data = await res.json(); setResults(data.results || []); setMeta(data.meta); const newSession: SearchSession = { id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `s-${Date.now()}-${Math.random().toString(36).slice(2)}`, query: query, timestamp: Date.now(), results: data.results || [], meta: data.meta, }; setSessions((prev) => [newSession, ...prev]); } catch (error) { console.error("Search failed", error); setResults([]); } finally { setIsSearching(false); } }; const handleExportBatch = () => { if (results.length === 0) return; const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(results, null, 2)); const downloadAnchorNode = document.createElement("a"); downloadAnchorNode.setAttribute("href", dataStr); downloadAnchorNode.setAttribute( "download", `QP_Search_Results_${Date.now()}.json`, ); document.body.appendChild(downloadAnchorNode); downloadAnchorNode.click(); downloadAnchorNode.remove(); }; const handleRestoreSession = (session: SearchSession) => { setQuery(session.query); setResults(session.results); setMeta(session.meta); setShowMore(false); setView("results"); }; const handleNewSearch = () => { setView("search"); setQuery(""); setResults([]); setShowMore(false); }; return ( <div className="min-h-screen flex flex-col bg-surface overflow-x-hidden"> <Navbar onHome={handleNewSearch} /> <main className="grow flex overflow-hidden"> {view === "results" && ( <aside className="w-60 shrink-0 border-r border-outline-variant bg-surface-container-low p-4 overflow-y-auto hidden lg:block"> <button onClick={handleNewSearch} className="w-full mb-8 py-3 primary-gradient text-white font-headline font-bold text-sm rounded-sm flex items-center justify-center gap-2 hover:shadow-lg transition-shadow" > <Plus size={18} /> 새 검색 </button> <div className="space-y-8"> <div> <div className="flex items-center justify-between mb-5"> <h4 className="text-[15px] font-bold text-on-surface uppercase tracking-tight"> 검색 조건 설정 </h4> <button onClick={() => setShowSidebarFilters(!showSidebarFilters)} className={`flex items-center gap-2 text-[12px] font-bold font-[Arial] leading-3.5 [border-style:ridge] transition-all duration-300 px-4 py-2 rounded-[4px] uppercase tracking-widest shadow-sm hover:shadow-md ${ showSidebarFilters ? "bg-[#E5E5E5] text-on-surface border-[#D1D1D1]" : "bg-white text-on-surface-variant border-outline-variant hover:bg-[#F5F5F5]" } border`} > <Filter size={16} className={ showSidebarFilters ? "text-on-surface" : "text-tertiary" } /> 조건 <ChevronDown size={14} className={`transition-transform duration-300 ${ showSidebarFilters ? "rotate-180" : "" }`} /> </button> </div> <AnimatePresence> {showSidebarFilters && ( <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.3 }} className="overflow-hidden border border-[#E1251B]/20 p-4 bg-[#FFF5F5]/50 rounded-[4px]" > <div className="space-y-6"> <div> <label className="text-[14px] font-bold text-on-surface uppercase mb-3 flex items-center gap-2"> <Globe size={16} className="text-[#8B1A1A]" /> 공장정보 </label> <select value={region} onChange={(e) => setRegion(e.target.value)} className="w-full bg-white border border-outline-variant text-[12px] rounded-sm p-2 focus:ring-1 focus:ring-[#B00020] focus:border-[#B00020] outline-none" > {REGION_OPTIONS.map((opt) => ( <option key={opt} value={opt}> {opt} </option> ))} </select> </div> <div> <label className="text-[14px] font-bold text-on-surface uppercase mb-3 flex items-center gap-2"> <Cpu size={16} className="text-[#8B1A1A]" /> 제품 정보 </label> <select value={product} onChange={(e) => setProduct(e.target.value)} className="w-full bg-white border border-outline-variant text-[12px] rounded-sm p-2 focus:ring-1 focus:ring-[#B00020] focus:border-[#B00020] outline-none" > {PRODUCT_OPTIONS.map((opt) => ( <option key={opt} value={opt}> {opt} </option> ))} </select> </div> </div> </motion.div> )} </AnimatePresence> </div> <div className="pt-10 border-t border-outline-variant mt-10"> <h4 className="text-[10px] font-bold text-tertiary uppercase tracking-widest mb-4"> 이전 검색 기록 </h4> <div className="space-y-4"> {sessions.length === 0 && ( <p className="text-[10px] text-tertiary/60 italic"> 이전 기록이 존재하지 않음 </p> )} {sessions.map((session) => ( <div key={session.id} onClick={() => handleRestoreSession(session)} className={`group cursor-pointer p-3 rounded-sm transition-all duration-300 ${ query === session.query ? "bg-surface-container-highest border-l-2 border-primary" : "hover:bg-surface-container-highest/50 border-l border-transparent" }`} > <p className="text-xs font-bold text-on-surface line-clamp-1 mb-1 group-hover:text-primary transition-colors"> {session.query} </p> <div className="flex justify-between items-center gap-4"> <span className="shrink-0 text-[9px] text-tertiary"> {new Date(session.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", })} </span> <span className="text-[9px] text-tertiary font-bold line-clamp-1 flex-grow text-right" title={ [...session.results].sort( (a, b) => b.relevance - a.relevance, )[0]?.title } > { [...session.results].sort( (a, b) => b.relevance - a.relevance, )[0]?.title } </span> </div> </div> ))} </div> </div> </div> </aside> )} <section className="flex-grow min-w-0 overflow-y-auto bg-surface-container-low/30 scroll-smooth pb-32"> <AnimatePresence mode="wait"> {view === "search" ? ( <motion.div key="search-view" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, y: -20 }} className="max-w-4xl mx-auto pt-24 pb-32 px-6 h-full" > <div className="absolute top-0 right-0 w-[600px] h-[600px] bg-surface-container-low rounded-full filter blur-3xl opacity-50 -translate-y-1/2 translate-x-1/3 pointer-events-none" /> <div className="text-center mb-16 relative z-10"> <motion.h2 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} className="font-headline font-extrabold text-[3.5rem] leading-tight tracking-[-0.02em] text-on-surface mb-3 items-baseline" > <span className="text-[#black] font-bold ml-3"> 지침서 검색 시스템 </span> <span className="text-[#E1251B]/90"> (SNAP)</span> </motion.h2> <motion.p initial={{ opacity: 0, y: 25 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.25 }} className="font-sans text-2xl text-on-surface-variant/70 tracking-widest mb-3" > <span className="text-[#E1251B]/90 font-bold"> Standard Navigation & Preview </span> </motion.p> <motion.p initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} className="font-bold text-lg text-on-surface-variant max-w-2xl mx-auto leading-relaxed" > SK 실트론의 메뉴얼 및 지침서, 행동 규칙 등을 검색해주세요. </motion.p> </div> <div className="flex justify-center mb-4 relative z-10"> <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }} className="text-blue-600 text-[17px] font-normal tracking-tight" > “제품Size와 공정명을 함께 기재하면 정보탐색의 정확도를 올릴 수 있습니다” </motion.p> </div> <div className="bg-surface-container-lowest shadow-ambient rounded-xl p-4 mb-4 relative z-10"> <div className="flex items-center bg-surface-container-highest rounded-lg px-4 py-3 focus-within:ring-2 focus-within:ring-primary/20 transition-all"> <Search className="text-tertiary mr-3" size={24} /> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} placeholder="예시 : 300mm EPI 공정 Reactor 점검 에서 wafer curling 제어 프로세스는?" className="w-full bg-transparent border-none text-on-surface font-sans text-lg focus:ring-0 placeholder:text-tertiary/60" /> <button onClick={handleSearch} disabled={isSearching} className="ml-2 px-8 py-3 font-headline font-bold text-sm tracking-wide bg-[#E1251B] hover:bg-[#C01F18] text-white rounded-[4px] transition-all duration-300 hover:scale-[1.02] active:scale-95 flex items-center gap-2 whitespace-nowrap shrink-0" > {isSearching ? ( <Loader2 className="animate-spin" size={18} /> ) : null} 검 색 </button> </div> </div> <div className="flex justify-end mb-8 relative z-10"> <button onClick={() => setShowFilters(!showFilters)} className={`flex items-center gap-2 text-[12px] font-bold font-[Arial] leading-[15px] [border-style:ridge] transition-all duration-300 px-5 py-2.5 rounded-[4px] uppercase tracking-widest shadow-sm hover:shadow-md border ${ showFilters ? "bg-[#E5E5E5] text-on-surface border-[#D1D1D1]" : "bg-white text-on-surface-variant border-outline-variant hover:bg-[#F5F5F5]" }`} > <Filter size={18} className={ showFilters ? "text-on-surface" : "text-tertiary" } /> 검색조건 <ChevronDown size={14} className={`transition-transform duration-300 ${ showFilters ? "rotate-180" : "" }`} /> </button> </div> <AnimatePresence> {showFilters && ( <motion.div initial={{ opacity: 0, y: -10, height: 0 }} animate={{ opacity: 1, y: 0, height: "auto" }} exit={{ opacity: 0, y: -10, height: 0 }} transition={{ duration: 0.3 }} className="overflow-hidden border border-[#E1251B]/20 p-4 bg-[#FFF5F5]/50 rounded-[4px]" > <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <FilterCard icon={Globe} title="공장정보" options={REGION_OPTIONS} value={region} onChange={setRegion} /> <FilterCard icon={Cpu} title="제품 정보" options={PRODUCT_OPTIONS} value={product} onChange={setProduct} /> </div> </motion.div> )} </AnimatePresence> </motion.div> ) : ( <motion.div key="results-view" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-full overflow-hidden" > <section className="flex-grow min-w-0 overflow-y-auto bg-surface-container-low/30 scroll-smooth pb-32"> <div className="sticky top-0 z-20 bg-surface-container-lowest/80 backdrop-blur-md border-b border-outline-variant px-8 py-2 flex items-center justify-between"> <div className="flex items-center gap-4 bg-surface-container-highest px-4 py-1.5 rounded-lg w-full max-w-lg"> <Search size={16} className="text-tertiary" /> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} className="bg-transparent border-none text-sm w-full focus:ring-0" /> </div> <div className="flex items-center gap-4"> <HelpCircle className="text-tertiary cursor-pointer" size={20} /> </div> </div> <div className="p-6 pt-4"> <div className="flex justify-between items-center mb-4 flex-wrap gap-3"> <div className="flex items-center gap-4 flex-wrap"> {isSearching ? ( <div className="flex items-center gap-3"> <Loader2 size={24} className="text-primary animate-spin" /> <h2 className="text-xl font-headline font-bold text-on-surface"> 검색중... </h2> </div> ) : ( <div className="flex items-center gap-3 flex-wrap"> <h2 className="text-xl font-headline font-bold text-on-surface flex items-center gap-3"> 검색 결과 <span className="bg-primary/10 text-primary px-3 py-0.5 rounded-full text-[10px] uppercase tracking-wide"> {query} </span> </h2> <span className="text-[10px] text-tertiary"> Found {meta?.total} relevant docs </span> {meta && ( <div className="flex items-center gap-2 ml-2 pl-3 border-l border-outline-variant/40 flex-wrap"> <span className="flex items-center gap-1.5 text-[10px] bg-surface-container-high px-2 py-1 rounded-sm"> <span className="text-tertiary uppercase tracking-wider"> Complexity </span> <span className="font-bold text-on-surface"> {meta.complexity} </span> </span> <span className="flex items-center gap-1.5 text-[10px] bg-surface-container-high px-2 py-1 rounded-sm"> <span className="text-tertiary uppercase tracking-wider"> Mode </span> <span className="font-bold text-on-surface"> {meta.strategy} </span> </span> <span className="flex items-center gap-1.5 text-[10px] bg-surface-container-high px-2 py-1 rounded-sm"> <span className="text-tertiary uppercase tracking-wider"> Answerability </span> <div className="w-16 bg-surface-container-highest h-1.5 rounded-full overflow-hidden"> <div className="bg-primary h-full transition-all duration-1000" style={{ width: `${(meta.answerabilityScore || 0) * 100}%`, }} /> </div> <span className="font-bold text-on-surface"> {( (meta.answerabilityScore || 0) * 100 ).toFixed(0)} % </span> </span> </div> )} </div> )} </div> {!isSearching && ( <div className="flex items-center gap-3"> <button onClick={handleExportBatch} className="flex items-center gap-2 text-[9px] font-bold uppercase text-tertiary hover:text-primary transition-colors tracking-widest bg-surface-container-highest/20 px-3 py-1 rounded-sm border border-outline-variant/30" > <Share2 size={12} /> EXPORT </button> </div> )} </div> {isSearching ? ( <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5"> {[...Array(6)].map((_, i) => ( <div key={i} className="bg-surface-container-highest/10 rounded-sm aspect-[4/3] animate-pulse border border-outline-variant/30" /> ))} </div> ) : ( <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5"> {results.slice(0, showMore ? 9 : 6).map((r) => ( <ResultCard key={r.id} result={r} onSelect={handleSelectResult} onDownloadLog={handleDownloadLog} /> ))} </div> )} {!isSearching && results.length > 6 && ( <div className="mt-8 flex justify-center"> <button onClick={() => setShowMore(!showMore)} className="flex items-center gap-2 px-6 py-1.5 border border-outline-variant rounded-[4px] text-[11px] font-bold text-tertiary hover:bg-surface-container-highest/30 transition-all uppercase tracking-widest" > {showMore ? "접기" : "더보기 (+3)"} <ChevronDown size={14} className={showMore ? "rotate-180" : ""} /> </button> </div> )} {!isSearching && results.length > 0 && ( <div className="mt-10 text-center py-6 border-t border-outline-variant/30"> <p className="text-xs text-tertiary"> All {results.length} results have been synthesized by the SNAP engine. </p> <button onClick={handleNewSearch} className="mt-4 text-primary font-bold text-xs uppercase tracking-widest hover:underline" > New Search </button> </div> )} </div> </section> </motion.div> )} </AnimatePresence> <AnimatePresence> {selectedResult && ( <DetailModal result={selectedResult} query={query} onClose={() => setSelectedResult(null)} onDownloadLog={handleDownloadLog} /> )} </AnimatePresence> </section> </main> </div> ); } // ───────────────────────────────────────────────────────────── // App — AuthProvider + 로그인 게이팅 래퍼 // ───────────────────────────────────────────────────────────── export default function App() { return ( <AuthProvider> <AuthGate /> </AuthProvider> ); } function AuthGate() { const { user, isLoading } = useAuth(); // 초기 로딩 중 (sessionStorage 복원 대기) if (isLoading) { return ( <div className="min-h-screen bg-surface flex items-center justify-center"> <Loader2 size={32} className="animate-spin text-[#E1251B]" /> </div> ); } // 비로그인 → 로그인 화면 if (!user) { return <LoginScreen />; } // 로그인됨 → SNAP UI return <AppContent />; }