QPIDS / src /App.tsx
Hyungseoky's picture
Update src/App.tsx
d62792b verified
Raw
History Blame Contribute Delete
72 kB
/**
* @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<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);
// 첫 마운트 μ‹œ 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 (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// 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 (
<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>
);
}
// ─────────────────────────────────────────────────────────────
// UserMenu β€” Navbar 우츑 μ‚¬μš©μž 메뉴
// ─────────────────────────────────────────────────────────────
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>
);
}
// ─────────────────────────────────────────────────────────────
// 링크 μΆ”μΆœ 헬퍼 β€” 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 생성
// 파일λͺ… κ·œμΉ™: <doc_id>_<title>_<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 />;
}