Spaces:
Running
Running
| import React, { useState, useEffect, useRef, useCallback } from 'react'; | |
| import { useUIStore, useReaderStore, useThemeStore } from '../../stores/appStore'; | |
| import { useSearchStore } from '../../stores/searchStore'; | |
| import type { SearchMode } from '../../stores/searchStore'; | |
| import api from '../../lib/api'; | |
| import { X, Book, Search, BarChart2, ChevronRight, Loader2, ChevronDown, Zap, Sparkles } from 'lucide-react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| // NavDrawer bg matches Shell bg per theme — seamless sidebar | |
| // Dark: bg=#1a1a2e text=#e0e0e0 border=#3a3a5e | |
| // Light: bg=#f8f6f0 text=#1a1a1a border=#e0d0b0 | |
| // Classic:bg=#f5edd8 text=#1a1a1a border=#d4c4a0 | |
| const DRAWER_STYLES: Record<string, { bg: string; text: string; border: string; tab: string; input: string }> = { | |
| dark: { bg: 'bg-[#1a1a2e]', text: 'text-[#e0e0e0]', border: 'border-[#3a3a5e]', tab: 'bg-[#222242]', input: 'bg-[#222242]' }, | |
| light: { bg: 'bg-[#f8f6f0]', text: 'text-[#1a1a1a]', border: 'border-[#e0d0b0]', tab: 'bg-[#fdfaf5]', input: 'bg-white' }, | |
| classic: { bg: 'bg-[#f5edd8]', text: 'text-[#1a1a1a]', border: 'border-[#d4c4a0]', tab: 'bg-[#fdfaf5]', input: 'bg-white' }, | |
| }; | |
| /** Helper to extract all highlighted terms inside <mark> tags from search snippets, | |
| * so we can highlight individual matched words in the reader page. | |
| * Falls back to the original query if no marks are found. */ | |
| const getHighlightQuery = (originalQuery: string, results: any[]): string => { | |
| const terms = new Set<string>(); | |
| const regex = /<mark>(.*?)<\/mark>/gi; | |
| results.forEach(res => { | |
| if (res.snippet) { | |
| let match; | |
| regex.lastIndex = 0; | |
| while ((match = regex.exec(res.snippet)) !== null) { | |
| const term = match[1].replace(/<[^>]+>/g, '').trim(); // clean any nested tags | |
| if (term.length > 1) { | |
| terms.add(term); | |
| } | |
| } | |
| } | |
| }); | |
| if (terms.size > 0) { | |
| return Array.from(terms).join(' '); | |
| } | |
| return originalQuery; | |
| }; | |
| const NavDrawer: React.FC = () => { | |
| const { isNavOpen, setNavOpen, activeTab, setActiveTab } = useUIStore(); | |
| const { currentVolume, setVolume, setPage, setHighlightQuery } = useReaderStore(); | |
| const { theme } = useThemeStore(); | |
| const { | |
| query, setQuery, results, totalResults, | |
| isLoading, isAiLoading, performSearch, performAiSearch, | |
| breakdown, timeTaken, searchMode, preferredMode, setPreferredMode, | |
| suggestions, showSuggestions, fetchSuggestions, setShowSuggestions, | |
| highlightWords, | |
| } = useSearchStore(); | |
| const [modeDropdownOpen, setModeDropdownOpen] = useState(false); | |
| const modeDropdownRef = useRef<HTMLDivElement>(null); | |
| const [expandedVols, setExpandedVols] = useState<Record<number, boolean>>({}); | |
| const toggleVolExpand = (volId: number) => { | |
| setExpandedVols(prev => ({ | |
| ...prev, | |
| [volId]: !prev[volId] | |
| })); | |
| }; | |
| const [toc, setToc] = useState<any[]>([]); | |
| const [loadingToc, setLoadingToc] = useState(false); | |
| const [volumes, setVolumes] = useState<any[]>([]); | |
| const [activeSuggestion, setActiveSuggestion] = useState(-1); | |
| const searchRef = useRef<HTMLFormElement>(null); | |
| const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| const getHighlightString = () => { | |
| if (highlightWords && highlightWords.length > 0) { | |
| return highlightWords.join(' '); | |
| } | |
| return getHighlightQuery(query, results); | |
| }; | |
| const s = DRAWER_STYLES[theme] ?? DRAWER_STYLES.dark; | |
| useEffect(() => { | |
| api.get('/api/pages/volumes').then(res => setVolumes(res.data)); | |
| }, []); | |
| useEffect(() => { | |
| if (activeTab !== 'toc') return; | |
| setLoadingToc(true); | |
| // volume_number = currentVolume → matches WHERE v.volume_number = ? | |
| api.get(`/api/pages/volumes/${currentVolume}/toc`) | |
| .then(res => setToc(res.data)) | |
| .finally(() => setLoadingToc(false)); | |
| }, [activeTab, currentVolume]); | |
| const jumpToPage = (volumeNumber: number, pageNum: number, highlight?: string) => { | |
| if (window.innerWidth < 1024) { | |
| setNavOpen(false); | |
| setTimeout(() => { | |
| setVolume(volumeNumber); | |
| setPage(pageNum); | |
| setHighlightQuery(highlight ?? ''); | |
| }, 250); | |
| } else { | |
| setVolume(volumeNumber); | |
| setPage(pageNum); | |
| setHighlightQuery(highlight ?? ''); | |
| } | |
| }; | |
| // Debounced suggestion fetch | |
| const handleQueryChange = useCallback((value: string) => { | |
| setQuery(value); | |
| setActiveSuggestion(-1); | |
| if (debounceRef.current) clearTimeout(debounceRef.current); | |
| debounceRef.current = setTimeout(() => { | |
| fetchSuggestions(value); | |
| }, 280); | |
| }, [setQuery, fetchSuggestions]); | |
| // Close suggestions + mode dropdown on outside click | |
| useEffect(() => { | |
| const handler = (e: MouseEvent) => { | |
| if (searchRef.current && !searchRef.current.contains(e.target as Node)) { | |
| setShowSuggestions(false); | |
| } | |
| if (modeDropdownRef.current && !modeDropdownRef.current.contains(e.target as Node)) { | |
| setModeDropdownOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handler); | |
| return () => document.removeEventListener('mousedown', handler); | |
| }, [setShowSuggestions]); | |
| const handleModeSelect = (mode: SearchMode) => { | |
| setPreferredMode(mode); | |
| setModeDropdownOpen(false); | |
| }; | |
| const selectSuggestion = (term: string) => { | |
| setQuery(term); | |
| setShowSuggestions(false); | |
| setActiveSuggestion(-1); | |
| setActiveTab('search'); | |
| setTimeout(() => performSearch(), 0); | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |
| if (!showSuggestions || suggestions.length === 0) { | |
| if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| setActiveTab('search'); | |
| performSearch(); | |
| } | |
| return; | |
| } | |
| if (e.key === 'ArrowDown') { | |
| e.preventDefault(); | |
| setActiveSuggestion(i => Math.min(i + 1, suggestions.length - 1)); | |
| } else if (e.key === 'ArrowUp') { | |
| e.preventDefault(); | |
| setActiveSuggestion(i => Math.max(i - 1, -1)); | |
| } else if (e.key === 'Enter') { | |
| e.preventDefault(); | |
| if (activeSuggestion >= 0) { | |
| selectSuggestion(suggestions[activeSuggestion]); | |
| } else { | |
| setShowSuggestions(false); | |
| setActiveTab('search'); | |
| performSearch(); | |
| } | |
| } else if (e.key === 'Escape') { | |
| setShowSuggestions(false); | |
| setActiveSuggestion(-1); | |
| } | |
| }; | |
| const handleSearch = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| setShowSuggestions(false); | |
| setActiveTab('search'); | |
| performSearch(); | |
| }; | |
| const tabs = [ | |
| { id: 'toc', icon: <Book size={18} />, label: 'สารบัญ' }, | |
| { id: 'search', icon: <Search size={18} />, label: 'ค้นหา' }, | |
| { id: 'overview', icon: <BarChart2 size={18} />, label: 'ภาพรวม' }, | |
| ] as const; | |
| return ( | |
| <> | |
| {/* Mobile backdrop */} | |
| <AnimatePresence> | |
| {isNavOpen && ( | |
| <motion.div | |
| key="backdrop" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| onClick={() => setNavOpen(false)} | |
| className="fixed inset-0 bg-black/50 z-40 lg:hidden" | |
| /> | |
| )} | |
| </AnimatePresence> | |
| {/* Drawer */} | |
| <motion.aside | |
| initial={false} | |
| animate={{ x: isNavOpen ? 0 : -320, width: isNavOpen ? 320 : 0 }} | |
| transition={{ type: 'tween', ease: 'easeOut', duration: 0.25 }} | |
| className={`fixed lg:relative inset-y-0 left-0 z-50 flex flex-col overflow-hidden border-r ${s.bg} ${s.text} ${s.border}`} | |
| > | |
| {/* Header */} | |
| <div className={`flex items-center justify-between px-4 py-3 border-b ${s.border}`}> | |
| <div className="flex flex-col"> | |
| <span className="font-bold text-sm leading-tight">พระไตรปิฎก</span> | |
| <span className="text-[10px] opacity-60 leading-tight">ฉบับมหาจุฬาลงกรณราชวิทยาลัย</span> | |
| </div> | |
| {/* X button — ทุก screen size (ไม่ใช่แค่ mobile) */} | |
| <button onClick={() => setNavOpen(false)} className="p-1 rounded hover:bg-black/5"> | |
| <X size={18} /> | |
| </button> | |
| </div> | |
| {/* Tab bar */} | |
| <div className={`flex border-b ${s.border}`}> | |
| {tabs.map(tab => ( | |
| <button | |
| key={tab.id} | |
| onClick={() => setActiveTab(tab.id)} | |
| className={`flex-1 flex flex-col items-center py-2.5 gap-1 text-[10px] font-bold uppercase tracking-wider transition-colors ${ | |
| activeTab === tab.id | |
| ? 'text-[#c8860a] border-b-2 border-[#c8860a]' | |
| : 'text-[#888] hover:text-[#555]' | |
| }`} | |
| > | |
| {tab.icon} | |
| {tab.label} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Content */} | |
| <div className="flex-1 overflow-y-auto"> | |
| {/* ── TOC ── */} | |
| {activeTab === 'toc' && ( | |
| <div className="p-3 space-y-3"> | |
| <div> | |
| <label className="text-[10px] font-bold text-[#888] uppercase block mb-1">เลือกเล่ม</label> | |
| <select | |
| value={currentVolume} | |
| onChange={e => setVolume(Number(e.target.value))} | |
| className={`w-full p-2 rounded-lg text-sm outline-none border ${s.border} ${s.input} ${s.text}`} | |
| > | |
| {volumes.map(v => ( | |
| <option key={v.id} value={v.volume_number}> | |
| เล่ม {v.volume_number} — {v.title} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| {loadingToc ? ( | |
| <div className="flex justify-center py-12"> | |
| <Loader2 size={20} className="animate-spin text-[#c8860a]" /> | |
| </div> | |
| ) : ( | |
| <div className="space-y-0.5"> | |
| {toc.map((item, i) => ( | |
| <button | |
| key={`${item.id}-${i}`} | |
| onClick={() => jumpToPage(currentVolume, item.page_number)} | |
| style={{ paddingLeft: `${(item.level ?? 0) * 12 + 8}px` }} | |
| className="w-full text-left py-2 pr-3 text-sm rounded flex items-start gap-2 hover:bg-white/10 transition-colors group" | |
| > | |
| <ChevronRight size={12} className="mt-1 opacity-30 group-hover:opacity-80 flex-shrink-0 text-[#c8860a]" /> | |
| <span className="flex-1 leading-snug">{item.title}</span> | |
| <span className="text-[10px] opacity-40 font-mono mt-0.5">{item.page_number}</span> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* ── Search ── */} | |
| {activeTab === 'search' && ( | |
| <div className="p-3 space-y-3"> | |
| <form onSubmit={handleSearch} className="relative" ref={searchRef}> | |
| {/* Search input */} | |
| <input | |
| type="text" | |
| value={query} | |
| onChange={e => handleQueryChange(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| onFocus={() => query && fetchSuggestions(query)} | |
| placeholder="ค้นหาข้อความ เช่น อริยสัจ…" | |
| autoComplete="off" | |
| className={`w-full pl-9 pr-3 py-2.5 rounded-xl text-sm outline-none border ${s.border} ${s.input} ${s.text} focus:border-[#c8860a] transition-colors`} | |
| /> | |
| <Search size={16} className="absolute left-3 top-3 text-[#888]" /> | |
| {isLoading && <Loader2 size={14} className="absolute right-3 top-3.5 animate-spin text-[#c8860a]" />} | |
| {/* Suggestions dropdown */} | |
| <AnimatePresence> | |
| {showSuggestions && suggestions.length > 0 && ( | |
| <motion.ul | |
| initial={{ opacity: 0, y: -4 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -4 }} | |
| transition={{ duration: 0.12 }} | |
| className={`absolute left-0 right-0 top-full mt-1 z-50 rounded-xl border shadow-lg overflow-hidden ${s.bg} ${s.border}`} | |
| > | |
| {suggestions.map((term, i) => ( | |
| <li key={term}> | |
| <button | |
| type="button" | |
| onMouseDown={() => selectSuggestion(term)} | |
| className={`w-full text-left px-4 py-2.5 text-sm flex items-center gap-2 transition-colors ${ | |
| i === activeSuggestion | |
| ? 'bg-[#c8860a]/15 text-[#c8860a]' | |
| : `${s.text} hover:bg-white/10` | |
| }`} | |
| > | |
| <Search size={12} className="opacity-40 flex-shrink-0" /> | |
| {term} | |
| </button> | |
| </li> | |
| ))} | |
| </motion.ul> | |
| )} | |
| </AnimatePresence> | |
| </form> | |
| {/* Split search button with mode dropdown */} | |
| <div className="flex gap-1.5" ref={modeDropdownRef}> | |
| {/* Main search button */} | |
| <button | |
| type="button" | |
| onClick={() => { setModeDropdownOpen(false); performSearch(); }} | |
| disabled={isLoading || isAiLoading || !query.trim()} | |
| className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-xl text-sm font-bold bg-[#c8860a] hover:bg-[#a86e08] disabled:opacity-50 disabled:cursor-not-allowed text-white transition-colors" | |
| > | |
| {isLoading ? ( | |
| <Loader2 size={14} className="animate-spin" /> | |
| ) : preferredMode === 'ai' ? ( | |
| <Sparkles size={14} /> | |
| ) : ( | |
| <Zap size={14} /> | |
| )} | |
| {preferredMode === 'ai' ? 'ค้นหาความหมาย' : 'ค้นหาคำ'} | |
| </button> | |
| {/* Mode dropdown toggle */} | |
| <div className="relative"> | |
| <button | |
| type="button" | |
| onClick={() => setModeDropdownOpen(o => !o)} | |
| className={`h-full px-2.5 rounded-xl border font-bold text-sm transition-colors ${s.border} ${s.tab} ${s.text} hover:border-[#c8860a]`} | |
| title="เลือกโหมดการค้นหา" | |
| > | |
| <ChevronDown size={14} className={`transition-transform ${modeDropdownOpen ? 'rotate-180' : ''}`} /> | |
| </button> | |
| <AnimatePresence> | |
| {modeDropdownOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: -4 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -4 }} | |
| transition={{ duration: 0.12 }} | |
| className={`absolute right-0 top-full mt-1 z-50 w-56 rounded-xl border shadow-lg overflow-hidden ${s.bg} ${s.border}`} | |
| > | |
| <button | |
| type="button" | |
| onClick={() => handleModeSelect('fts')} | |
| className={`w-full text-left px-4 py-3 text-sm flex items-start gap-3 transition-colors ${ | |
| preferredMode === 'fts' ? 'bg-[#c8860a]/15' : `hover:bg-white/10` | |
| }`} | |
| > | |
| <Zap size={16} className="mt-0.5 text-[#c8860a] flex-shrink-0" /> | |
| <div> | |
| <p className={`font-bold text-sm ${s.text}`}>ค้นหาคำ <span className="text-[10px] font-normal opacity-60">(เร็ว)</span></p> | |
| <p className="text-[10px] text-[#888] mt-0.5">ค้นชื่อคน สถานที่ คำบาลี</p> | |
| </div> | |
| {preferredMode === 'fts' && <span className="ml-auto text-[#c8860a] text-xs">✓</span>} | |
| </button> | |
| <div className={`h-px ${s.border} bg-current opacity-20`} /> | |
| <button | |
| type="button" | |
| onClick={() => handleModeSelect('ai')} | |
| className={`w-full text-left px-4 py-3 text-sm flex items-start gap-3 transition-colors ${ | |
| preferredMode === 'ai' ? 'bg-[#c8860a]/15' : `hover:bg-white/10` | |
| }`} | |
| > | |
| <Sparkles size={16} className="mt-0.5 text-[#c8860a] flex-shrink-0" /> | |
| <div> | |
| <p className={`font-bold text-sm ${s.text}`}>ค้นหาความหมาย <span className="text-[10px] font-normal opacity-60">(AI)</span></p> | |
| <p className="text-[10px] text-[#888] mt-0.5">ค้นหาตามใจความ เหมาะกับคำถาม</p> | |
| </div> | |
| {preferredMode === 'ai' && <span className="ml-auto text-[#c8860a] text-xs">✓</span>} | |
| </button> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| {results.length > 0 && ( | |
| <> | |
| {/* Result count + mode badge */} | |
| <div className="flex items-center justify-between"> | |
| <p className="text-[10px] text-[#888] font-bold uppercase"> | |
| พบ {totalResults} หน้า ({timeTaken.toFixed(0)}ms) | |
| </p> | |
| <span className={`text-[9px] font-bold px-2 py-0.5 rounded-full ${ | |
| searchMode === 'ai' | |
| ? 'bg-purple-500/20 text-purple-400' | |
| : 'bg-amber-500/20 text-amber-500' | |
| }`}> | |
| {searchMode === 'ai' ? '✦ AI' : '⚡ FTS'} | |
| </span> | |
| </div> | |
| {/* Volume breakdown bars — แสดงทุกเล่มที่พบ ไม่จำกัดจำนวน */} | |
| {Object.keys(breakdown).length > 1 && ( | |
| <div className={`p-3 rounded-xl border ${s.border} ${s.tab} space-y-2`}> | |
| <p className="text-[10px] font-bold text-[#888] uppercase mb-1">การกระจายตัว</p> | |
| {Object.entries(breakdown) | |
| .sort((a, b) => Number(a[0]) - Number(b[0])) | |
| .map(([volId, stats]) => { | |
| const numericVolId = Number(volId); | |
| const vol = volumes.find(v => v.id === numericVolId); | |
| const maxCount = Math.max(...Object.values(breakdown).map(s => s.count)); | |
| const pct = (stats.count / maxCount) * 100; | |
| const isExpanded = !!expandedVols[numericVolId]; | |
| const hasPages = stats.pages && stats.pages.length > 0; | |
| return ( | |
| <div key={volId} className="space-y-1.5 border-b border-white/5 last:border-b-0 pb-1.5 last:pb-0"> | |
| {/* Header Bar Area (คลิกเพื่อขยาย) */} | |
| <div | |
| onClick={() => toggleVolExpand(numericVolId)} | |
| className="w-full text-left space-y-0.5 cursor-pointer group/bar" | |
| > | |
| <div className="flex justify-between text-[9px] text-[#888] group-hover/bar:text-[#c8860a] transition-colors font-bold"> | |
| <span className="truncate max-w-[185px]">เล่ม {vol?.volume_number} — {vol?.title}</span> | |
| <span className="flex items-center gap-1"> | |
| <span>{stats.count}</span> | |
| {hasPages && ( | |
| <ChevronDown size={10} className={`transform transition-transform ${isExpanded ? 'rotate-180 text-[#c8860a]' : 'opacity-40'}`} /> | |
| )} | |
| </span> | |
| </div> | |
| <div className="h-1 bg-white/10 rounded-full"> | |
| <motion.div | |
| initial={{ width: 0 }} | |
| animate={{ width: `${pct}%` }} | |
| className="h-full bg-[#c8860a] rounded-full group-hover/bar:bg-[#9a6307] transition-colors" | |
| /> | |
| </div> | |
| </div> | |
| {/* Expanded Page Chips */} | |
| <AnimatePresence> | |
| {isExpanded && hasPages && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: 'auto', opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| transition={{ duration: 0.2 }} | |
| className="overflow-hidden pl-1 pr-1 pt-1" | |
| > | |
| <div className="flex flex-wrap gap-1 max-h-36 overflow-y-auto custom-scrollbar"> | |
| {stats.pages?.map((pageNum) => ( | |
| <button | |
| key={pageNum} | |
| type="button" | |
| onClick={() => vol && jumpToPage(vol.volume_number, pageNum, getHighlightString())} | |
| className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-white/10 hover:bg-[#c8860a] hover:text-white transition-colors text-center min-w-[32px] border border-white/5" | |
| > | |
| {pageNum} | |
| </button> | |
| ))} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {/* Result cards */} | |
| <div className="space-y-2"> | |
| {results.map((res, i) => { | |
| const vol = volumes.find(v => v.id === res.volume_id); | |
| const volNum = res.volume_number ?? vol?.volume_number; | |
| return ( | |
| <div | |
| key={i} | |
| className={`w-full text-left p-3 rounded-xl border ${s.border} ${s.tab} hover:border-[#c8860a] hover:shadow-sm transition-all`} | |
| > | |
| {/* Google-style breadcrumb */} | |
| <div className="flex items-center flex-wrap gap-x-0.5 gap-y-0.5 mb-1.5 text-[10px] font-bold"> | |
| {/* เล่ม N */} | |
| <button | |
| type="button" | |
| onClick={() => volNum && jumpToPage(volNum, 1)} | |
| className="text-[#c8860a] hover:underline hover:text-[#e0980f] transition-colors" | |
| > | |
| เล่ม {volNum} | |
| </button> | |
| {/* › พระสูตร/หัวข้อ */} | |
| {res.toc_title && ( | |
| <> | |
| <span className="text-[#888] mx-0.5">›</span> | |
| <button | |
| type="button" | |
| onClick={() => volNum && res.toc_page && jumpToPage(volNum, res.toc_page, getHighlightString())} | |
| className="text-[#c8860a] hover:underline hover:text-[#e0980f] transition-colors max-w-[160px] truncate text-left" | |
| title={res.toc_title} | |
| > | |
| {res.toc_title} | |
| </button> | |
| </> | |
| )} | |
| {/* › หน้า N */} | |
| <> | |
| <span className="text-[#888] mx-0.5">›</span> | |
| <button | |
| type="button" | |
| onClick={() => volNum && jumpToPage(volNum, res.page_number, getHighlightString())} | |
| className="text-[#888] hover:text-[#c8860a] hover:underline transition-colors" | |
| > | |
| หน้า {res.page_number} | |
| </button> | |
| </> | |
| </div> | |
| {/* Snippet — กดทั้ง card ไปหน้าที่พบ */} | |
| <button | |
| type="button" | |
| onClick={() => volNum && jumpToPage(volNum, res.page_number, getHighlightString())} | |
| className="w-full text-left" | |
| > | |
| <span | |
| className="block text-xs text-[#aaa] line-clamp-3 leading-relaxed" | |
| dangerouslySetInnerHTML={{ __html: res.snippet }} | |
| /> | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| {/* AI upgrade button — แสดงเฉพาะเมื่อผลเป็น FTS และ preferredMode ไม่ใช่ ai */} | |
| {searchMode === 'fts' && ( | |
| <button | |
| type="button" | |
| onClick={() => performAiSearch()} | |
| disabled={isAiLoading} | |
| className={`w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl border text-sm font-bold transition-all ${ | |
| isAiLoading | |
| ? `${s.border} opacity-60 cursor-not-allowed ${s.text}` | |
| : `border-purple-500/40 text-purple-400 hover:bg-purple-500/10 hover:border-purple-500/70` | |
| }`} | |
| > | |
| {isAiLoading ? ( | |
| <><Loader2 size={14} className="animate-spin" /> กำลังค้นหาด้วย AI…</> | |
| ) : ( | |
| <><Sparkles size={14} /> ค้นหาเพิ่มเติมด้วย AI</> | |
| )} | |
| </button> | |
| )} | |
| </> | |
| )} | |
| {!isLoading && results.length === 0 && query && ( | |
| <div className="text-center py-12 space-y-4"> | |
| <div className="opacity-40"> | |
| <Search size={36} className="mx-auto mb-3 opacity-20" /> | |
| <p className="text-sm">ไม่พบ «{query}»</p> | |
| </div> | |
| {searchMode === 'fts' && ( | |
| <div className="pt-2 px-4"> | |
| <button | |
| type="button" | |
| onClick={() => performAiSearch()} | |
| disabled={isAiLoading} | |
| className={`w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl border text-sm font-bold transition-all ${ | |
| isAiLoading | |
| ? `${s.border} opacity-60 cursor-not-allowed ${s.text}` | |
| : `border-purple-500/40 text-purple-400 hover:bg-purple-500/10 hover:border-purple-500/70` | |
| }`} | |
| > | |
| {isAiLoading ? ( | |
| <><Loader2 size={14} className="animate-spin" /> กำลังค้นหาด้วย AI…</> | |
| ) : ( | |
| <><Sparkles size={14} /> ลองค้นหาความหมายด้วย AI</> | |
| )} | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* ── Overview ── */} | |
| {activeTab === 'overview' && ( | |
| <div className="p-4 space-y-4 text-sm"> | |
| <div className={`p-4 rounded-xl border ${s.border} ${s.tab}`}> | |
| <p className="font-bold text-[#c8860a] mb-1">พระไตรปิฎก AI Expert</p> | |
| <p className="text-xs text-[#888] leading-relaxed"> | |
| โครงการสืบค้นพระไตรปิฎก ฉบับมหาจุฬาลงกรณราชวิทยาลัย ด้วย AI และ Semantic Search | |
| </p> | |
| </div> | |
| <div> | |
| <p className="text-[10px] font-bold text-[#888] uppercase mb-2">เทคโนโลยีที่ใช้</p> | |
| <div className="grid grid-cols-2 gap-2 text-[10px] font-bold text-[#aaa]"> | |
| {['FastAPI + Python', 'Vite + React', 'SQLite FTS5', 'Turbovec + Reranker'].map(t => ( | |
| <div key={t} className={`p-2 rounded-lg border ${s.border} ${s.tab}`}>{t}</div> | |
| ))} | |
| </div> | |
| </div> | |
| <div> | |
| <p className="text-[10px] font-bold text-[#888] uppercase mb-2">แหล่งข้อมูลอ้างอิง</p> | |
| <div className={`p-4 rounded-xl border ${s.border} ${s.tab} text-xs text-[#888] space-y-3`}> | |
| <p className="leading-relaxed"> | |
| พระไตรปิฎกฉบับมหาจุฬาลงกรณราชวิทยาลัย อ้างอิงต้นฉบับดิจิทัลและเผยแผ่โดยมีเว็บไซต์{' '} | |
| <a | |
| href="https://84000.org" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-[#c8860a] hover:underline font-semibold" | |
| > | |
| 84000.org | |
| </a>{' '} | |
| เป็นแหล่งข้อมูลปฐมภูมิ (Primary Data Source) ของโครงการ | |
| </p> | |
| <div className="p-2.5 rounded bg-black/10 text-[10px] text-[#aaa] border-l-2 border-[#c8860a]/60 leading-relaxed italic"> | |
| <strong>ข้อสงวนสิทธิ์ (Disclaimer):</strong> “84000.org เป็นผู้เอื้อเฟื้อข้อมูลต้นฉบับ แต่ไม่มีส่วนรับผิดชอบต่อผลลัพธ์ที่เกิดจากการประมวลผลของระบบ AI” | |
| </div> | |
| <hr className={`border-t ${s.border}`} /> | |
| <a | |
| href="https://84000.org/tipitaka/read/m_siri.php?B=1&siri=1&fontsz=0" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="inline-flex items-center text-[#c8860a] hover:text-[#e0980f] font-semibold transition-colors gap-1 text-[11px]" | |
| > | |
| <span>เข้าชมเว็บไซต์ต้นฉบับ</span> | |
| <ChevronRight size={12} className="mt-[1px]" /> | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </motion.aside> | |
| </> | |
| ); | |
| }; | |
| export default NavDrawer; | |