| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| |
| |
| interface AuthContextType { |
| user: AuthUser | null; |
| isLoading: boolean; |
| login: (username: string, password: string) => Promise<void>; |
| logout: () => Promise<void>; |
| } |
|
|
| const AuthContext = createContext<AuthContextType>({ |
| 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<AuthUser | null>(null); |
| const [isLoading, setIsLoading] = useState(true); |
|
|
| |
| 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 ( |
| <AuthContext.Provider value={{ user, isLoading, login, logout }}> |
| {children} |
| </AuthContext.Provider> |
| ); |
| } |
|
|
| |
| |
| |
| 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 ( |
| <div className="min-h-screen bg-surface flex items-center justify-center p-4"> |
| <motion.div |
| initial={{ opacity: 0, y: 20 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ duration: 0.5 }} |
| className="w-full max-w-md" |
| > |
| {/* λ‘κ³ */} |
| <div className="text-center mb-10"> |
| <h1 className="text-[#E1251B] font-headline font-black text-5xl tracking-tighter mb-2"> |
| SNAP |
| </h1> |
| <p className="text-on-surface-variant text-sm tracking-wide"> |
| SKμ€νΈλ‘ μ¬λ΄ μ§μΉ¨μ κ²μ μμ€ν
|
| </p> |
| </div> |
| |
| {/* λ‘κ·ΈμΈ νΌ */} |
| <div className="glass-panel rounded-lg p-8 shadow-xl border border-outline-variant/30"> |
| <form onSubmit={handleSubmit} className="space-y-5"> |
| {/* μ¬λ² */} |
| <div> |
| <label className="block text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2"> |
| μ¬λ² |
| </label> |
| <div className="relative"> |
| <User |
| size={16} |
| className="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant" |
| /> |
| <input |
| type="text" |
| value={username} |
| onChange={(e) => 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" |
| /> |
| </div> |
| </div> |
| |
| {/* λΉλ°λ²νΈ */} |
| <div> |
| <label className="block text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-2"> |
| λΉλ°λ²νΈ |
| </label> |
| <div className="relative"> |
| <Lock |
| size={16} |
| className="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant" |
| /> |
| <input |
| type="password" |
| value={password} |
| onChange={(e) => 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" |
| /> |
| </div> |
| </div> |
| |
| {/* μλ¬ λ©μμ§ */} |
| <AnimatePresence> |
| {error && ( |
| <motion.div |
| initial={{ opacity: 0, height: 0 }} |
| animate={{ opacity: 1, height: "auto" }} |
| exit={{ opacity: 0, height: 0 }} |
| className="flex items-center gap-2 text-[#E1251B] text-sm bg-[#E1251B]/10 rounded-md px-3 py-2" |
| > |
| <AlertCircle size={14} /> |
| {error} |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* λ‘κ·ΈμΈ λ²νΌ */} |
| <button |
| type="submit" |
| disabled={isSubmitting} |
| className="w-full py-3 primary-gradient text-white font-headline font-bold text-sm rounded-md flex items-center justify-center gap-2 hover:shadow-lg transition-all disabled:opacity-60 disabled:cursor-not-allowed" |
| > |
| {isSubmitting ? ( |
| <> |
| <Loader2 size={16} className="animate-spin" /> |
| μΈμ¦ μ€... |
| </> |
| ) : ( |
| "λ‘κ·ΈμΈ" |
| )} |
| </button> |
| </form> |
| |
| <p className="text-center text-on-surface-variant/60 text-xs mt-6"> |
| μ¬λ΄ AD κ³μ (μ¬λ²)μΌλ‘ λ‘κ·ΈμΈν©λλ€ |
| </p> |
| </div> |
| |
| <p className="text-center text-on-surface-variant/40 text-xs mt-8"> |
| Β© SK siltron Β· Generative AI Team |
| </p> |
| </motion.div> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| function UserMenu() { |
| const { user, logout } = useAuth(); |
| const [open, setOpen] = useState(false); |
|
|
| if (!user) return null; |
|
|
| return ( |
| <div className="relative"> |
| <button |
| onClick={() => setOpen(!open)} |
| className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-surface-container-high/30 transition-colors" |
| > |
| <div className="w-7 h-7 rounded-full bg-[#E1251B] flex items-center justify-center text-white text-xs font-bold"> |
| {user.name.charAt(0)} |
| </div> |
| <span className="hidden md:inline text-sm text-on-surface font-medium"> |
| {user.name} |
| </span> |
| <ChevronDown |
| size={14} |
| className={`text-on-surface-variant transition-transform ${open ? "rotate-180" : ""}`} |
| /> |
| </button> |
| |
| <AnimatePresence> |
| {open && ( |
| <> |
| {/* ν΄λ¦ μΈλΆ λ«κΈ°μ© μ€λ²λ μ΄ */} |
| <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} /> |
| |
| <motion.div |
| initial={{ opacity: 0, y: -8, scale: 0.95 }} |
| animate={{ opacity: 1, y: 0, scale: 1 }} |
| exit={{ opacity: 0, y: -8, scale: 0.95 }} |
| transition={{ duration: 0.15 }} |
| className="absolute right-0 top-full mt-2 w-64 z-50 glass-panel rounded-lg shadow-xl border border-outline-variant/30 overflow-hidden" |
| > |
| {/* μ¬μ©μ μ 보 */} |
| <div className="px-4 py-4 border-b border-outline-variant/20"> |
| <p className="font-bold text-on-surface text-sm">{user.name}</p> |
| <p className="text-on-surface-variant text-xs mt-0.5"> |
| {user.department} Β· {user.title} |
| </p> |
| <p className="text-on-surface-variant/60 text-xs mt-0.5"> |
| {user.email} |
| </p> |
| </div> |
| |
| {/* λ‘κ·Έμμ */} |
| <button |
| onClick={async () => { |
| setOpen(false); |
| await logout(); |
| }} |
| className="w-full px-4 py-3 flex items-center gap-2 text-sm text-on-surface hover:bg-surface-container-high/30 transition-colors" |
| > |
| <LogOut size={14} /> |
| λ‘κ·Έμμ |
| </button> |
| </motion.div> |
| </> |
| )} |
| </AnimatePresence> |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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}`; |
| } |
|
|
| |
| |
| |
| 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> |
| ); |
|
|
| |
| |
| |
| 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> |
| ); |
|
|
| |
| |
| |
| |
| |
| 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> |
| ); |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| const templateRelPath = |
| pages?.find((p) => p.display_rel_path)?.display_rel_path ?? null; |
|
|
| |
| |
| |
| const matched = pages?.find((p) => p.page === currentPage); |
| const pageThumb = |
| matched?.thumbnail || |
| buildPageThumbUrl(templateRelPath, currentPage) || |
| result.thumbnail; |
|
|
| |
| useEffect(() => { |
| setImgError(false); |
| }, [currentPage]); |
|
|
| |
| 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(() => { |
| |
| }); |
| return () => { |
| cancelled = true; |
| }; |
| }, [result.id]); |
|
|
| |
| 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]); |
|
|
| |
| 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]); |
|
|
| |
| 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]; |
| }); |
| }; |
|
|
| |
| 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> |
| ); |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| 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(() => {}); |
| }, |
| [user], |
| ); |
|
|
| |
| const handleSelectResult = useCallback( |
| (result: SearchResult) => { |
| setSelectedResult(result); |
| sendLog("detail", { |
| docId: result.docId || result.id, |
| query, |
| }); |
| }, |
| [query, sendLog], |
| ); |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| |
| |
| export default function App() { |
| return ( |
| <AuthProvider> |
| <AuthGate /> |
| </AuthProvider> |
| ); |
| } |
|
|
| function AuthGate() { |
| const { user, isLoading } = useAuth(); |
|
|
| |
| 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 />; |
| } |
|
|
| |
| return <AppContent />; |
| } |