Spaces:
Running
Running
Commit ·
c1f9028
1
Parent(s): 8c12cef
Implement expandable volume breakdown with page chips in search panel
Browse files
webapp/tipitaka-api/app/schemas.py
CHANGED
|
@@ -45,7 +45,7 @@ class SearchResponse(BaseModel):
|
|
| 45 |
total_results: int
|
| 46 |
results: List[SearchResultItem]
|
| 47 |
time_taken_ms: float
|
| 48 |
-
breakdown_by_volume: Dict[int, Dict[str,
|
| 49 |
search_mode: str = "fts"
|
| 50 |
highlight_words: List[str] = Field(default_factory=list)
|
| 51 |
|
|
|
|
| 45 |
total_results: int
|
| 46 |
results: List[SearchResultItem]
|
| 47 |
time_taken_ms: float
|
| 48 |
+
breakdown_by_volume: Dict[int, Dict[str, Any]]
|
| 49 |
search_mode: str = "fts"
|
| 50 |
highlight_words: List[str] = Field(default_factory=list)
|
| 51 |
|
webapp/tipitaka-api/app/services/search_service.py
CHANGED
|
@@ -636,7 +636,8 @@ class SearchService:
|
|
| 636 |
cursor.execute("""
|
| 637 |
SELECT p.volume_id, COUNT(*) as cnt,
|
| 638 |
MIN(p.page_number) as first_page,
|
| 639 |
-
MAX(p.page_number) as last_page
|
|
|
|
| 640 |
FROM pages_fts f
|
| 641 |
JOIN pages p ON f.rowid = p.id
|
| 642 |
WHERE pages_fts MATCH ?
|
|
@@ -644,12 +645,17 @@ class SearchService:
|
|
| 644 |
ORDER BY p.volume_id
|
| 645 |
""", (fts_q,))
|
| 646 |
for row in cursor.fetchall():
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
breakdown_data[row["volume_id"]] = {
|
| 648 |
"count": row["cnt"],
|
| 649 |
"first_page": row["first_page"],
|
| 650 |
"last_page": row["last_page"],
|
|
|
|
| 651 |
}
|
| 652 |
-
total_count = sum(
|
| 653 |
except Exception:
|
| 654 |
pass
|
| 655 |
|
|
|
|
| 636 |
cursor.execute("""
|
| 637 |
SELECT p.volume_id, COUNT(*) as cnt,
|
| 638 |
MIN(p.page_number) as first_page,
|
| 639 |
+
MAX(p.page_number) as last_page,
|
| 640 |
+
group_concat(p.page_number, ',') as page_list
|
| 641 |
FROM pages_fts f
|
| 642 |
JOIN pages p ON f.rowid = p.id
|
| 643 |
WHERE pages_fts MATCH ?
|
|
|
|
| 645 |
ORDER BY p.volume_id
|
| 646 |
""", (fts_q,))
|
| 647 |
for row in cursor.fetchall():
|
| 648 |
+
raw_pages = row["page_list"]
|
| 649 |
+
pages = []
|
| 650 |
+
if raw_pages:
|
| 651 |
+
pages = sorted(list(set(int(x) for x in raw_pages.split(",") if x.strip())))
|
| 652 |
breakdown_data[row["volume_id"]] = {
|
| 653 |
"count": row["cnt"],
|
| 654 |
"first_page": row["first_page"],
|
| 655 |
"last_page": row["last_page"],
|
| 656 |
+
"pages": pages,
|
| 657 |
}
|
| 658 |
+
total_count = sum(v["count"] for v in breakdown_data.values())
|
| 659 |
except Exception:
|
| 660 |
pass
|
| 661 |
|
webapp/tipitaka-web/src/components/layout/NavDrawer.tsx
CHANGED
|
@@ -55,6 +55,14 @@ const NavDrawer: React.FC = () => {
|
|
| 55 |
|
| 56 |
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
|
| 57 |
const modeDropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
const [toc, setToc] = useState<any[]>([]);
|
| 60 |
const [loadingToc, setLoadingToc] = useState(false);
|
|
@@ -418,32 +426,69 @@ const NavDrawer: React.FC = () => {
|
|
| 418 |
|
| 419 |
{/* Volume breakdown bars — แสดงทุกเล่มที่พบ ไม่จำกัดจำนวน */}
|
| 420 |
{Object.keys(breakdown).length > 1 && (
|
| 421 |
-
<div className={`p-3 rounded-xl border ${s.border} ${s.tab} space-y-
|
| 422 |
<p className="text-[10px] font-bold text-[#888] uppercase mb-1">การกระจายตัว</p>
|
| 423 |
{Object.entries(breakdown)
|
| 424 |
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
| 425 |
.map(([volId, stats]) => {
|
| 426 |
-
const
|
|
|
|
| 427 |
const maxCount = Math.max(...Object.values(breakdown).map(s => s.count));
|
| 428 |
const pct = (stats.count / maxCount) * 100;
|
|
|
|
|
|
|
|
|
|
| 429 |
return (
|
| 430 |
-
<
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
<
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
</div>
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
);
|
| 448 |
})}
|
| 449 |
</div>
|
|
|
|
| 55 |
|
| 56 |
const [modeDropdownOpen, setModeDropdownOpen] = useState(false);
|
| 57 |
const modeDropdownRef = useRef<HTMLDivElement>(null);
|
| 58 |
+
const [expandedVols, setExpandedVols] = useState<Record<number, boolean>>({});
|
| 59 |
+
|
| 60 |
+
const toggleVolExpand = (volId: number) => {
|
| 61 |
+
setExpandedVols(prev => ({
|
| 62 |
+
...prev,
|
| 63 |
+
[volId]: !prev[volId]
|
| 64 |
+
}));
|
| 65 |
+
};
|
| 66 |
|
| 67 |
const [toc, setToc] = useState<any[]>([]);
|
| 68 |
const [loadingToc, setLoadingToc] = useState(false);
|
|
|
|
| 426 |
|
| 427 |
{/* Volume breakdown bars — แสดงทุกเล่มที่พบ ไม่จำกัดจำนวน */}
|
| 428 |
{Object.keys(breakdown).length > 1 && (
|
| 429 |
+
<div className={`p-3 rounded-xl border ${s.border} ${s.tab} space-y-2`}>
|
| 430 |
<p className="text-[10px] font-bold text-[#888] uppercase mb-1">การกระจายตัว</p>
|
| 431 |
{Object.entries(breakdown)
|
| 432 |
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
| 433 |
.map(([volId, stats]) => {
|
| 434 |
+
const numericVolId = Number(volId);
|
| 435 |
+
const vol = volumes.find(v => v.id === numericVolId);
|
| 436 |
const maxCount = Math.max(...Object.values(breakdown).map(s => s.count));
|
| 437 |
const pct = (stats.count / maxCount) * 100;
|
| 438 |
+
const isExpanded = !!expandedVols[numericVolId];
|
| 439 |
+
const hasPages = stats.pages && stats.pages.length > 0;
|
| 440 |
+
|
| 441 |
return (
|
| 442 |
+
<div key={volId} className="space-y-1.5 border-b border-white/5 last:border-b-0 pb-1.5 last:pb-0">
|
| 443 |
+
{/* Header Bar Area (คลิกเพื่อขยาย) */}
|
| 444 |
+
<div
|
| 445 |
+
onClick={() => toggleVolExpand(numericVolId)}
|
| 446 |
+
className="w-full text-left space-y-0.5 cursor-pointer group/bar"
|
| 447 |
+
>
|
| 448 |
+
<div className="flex justify-between text-[9px] text-[#888] group-hover/bar:text-[#c8860a] transition-colors font-bold">
|
| 449 |
+
<span className="truncate max-w-[185px]">เล่ม {vol?.volume_number} — {vol?.title}</span>
|
| 450 |
+
<span className="flex items-center gap-1">
|
| 451 |
+
<span>{stats.count}</span>
|
| 452 |
+
{hasPages && (
|
| 453 |
+
<ChevronDown size={10} className={`transform transition-transform ${isExpanded ? 'rotate-180 text-[#c8860a]' : 'opacity-40'}`} />
|
| 454 |
+
)}
|
| 455 |
+
</span>
|
| 456 |
+
</div>
|
| 457 |
+
<div className="h-1 bg-white/10 rounded-full">
|
| 458 |
+
<motion.div
|
| 459 |
+
initial={{ width: 0 }}
|
| 460 |
+
animate={{ width: `${pct}%` }}
|
| 461 |
+
className="h-full bg-[#c8860a] rounded-full group-hover/bar:bg-[#9a6307] transition-colors"
|
| 462 |
+
/>
|
| 463 |
+
</div>
|
| 464 |
</div>
|
| 465 |
+
|
| 466 |
+
{/* Expanded Page Chips */}
|
| 467 |
+
<AnimatePresence>
|
| 468 |
+
{isExpanded && hasPages && (
|
| 469 |
+
<motion.div
|
| 470 |
+
initial={{ height: 0, opacity: 0 }}
|
| 471 |
+
animate={{ height: 'auto', opacity: 1 }}
|
| 472 |
+
exit={{ height: 0, opacity: 0 }}
|
| 473 |
+
transition={{ duration: 0.2 }}
|
| 474 |
+
className="overflow-hidden pl-1 pr-1 pt-1"
|
| 475 |
+
>
|
| 476 |
+
<div className="flex flex-wrap gap-1 max-h-36 overflow-y-auto custom-scrollbar">
|
| 477 |
+
{stats.pages?.map((pageNum) => (
|
| 478 |
+
<button
|
| 479 |
+
key={pageNum}
|
| 480 |
+
type="button"
|
| 481 |
+
onClick={() => vol && jumpToPage(vol.volume_number, pageNum, getHighlightString())}
|
| 482 |
+
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"
|
| 483 |
+
>
|
| 484 |
+
{pageNum}
|
| 485 |
+
</button>
|
| 486 |
+
))}
|
| 487 |
+
</div>
|
| 488 |
+
</motion.div>
|
| 489 |
+
)}
|
| 490 |
+
</AnimatePresence>
|
| 491 |
+
</div>
|
| 492 |
);
|
| 493 |
})}
|
| 494 |
</div>
|
webapp/tipitaka-web/src/stores/searchStore.ts
CHANGED
|
@@ -17,6 +17,7 @@ interface SearchResult {
|
|
| 17 |
interface VolumeStats {
|
| 18 |
count: number;
|
| 19 |
first_page: number;
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
const PREF_KEY = 'tipitaka_search_mode';
|
|
|
|
| 17 |
interface VolumeStats {
|
| 18 |
count: number;
|
| 19 |
first_page: number;
|
| 20 |
+
pages?: number[];
|
| 21 |
}
|
| 22 |
|
| 23 |
const PREF_KEY = 'tipitaka_search_mode';
|