dhammawatthumpra commited on
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, int]]
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(r["cnt"] for r in cursor.fetchall()) or sum(v["count"] for v in breakdown_data.values())
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-1.5`}>
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 vol = volumes.find(v => v.id === Number(volId));
 
427
  const maxCount = Math.max(...Object.values(breakdown).map(s => s.count));
428
  const pct = (stats.count / maxCount) * 100;
 
 
 
429
  return (
430
- <button
431
- key={volId}
432
- onClick={() => vol && jumpToPage(vol.volume_number, stats.first_page, getHighlightString())}
433
- className="w-full text-left space-y-0.5 group/bar"
434
- >
435
- <div className="flex justify-between text-[9px] text-[#888] group-hover/bar:text-[#c8860a] transition-colors">
436
- <span className="truncate max-w-[180px]">เล่ม {vol?.volume_number}</span>
437
- <span>{stats.count}</span>
438
- </div>
439
- <div className="h-1 bg-white/10 rounded-full">
440
- <motion.div
441
- initial={{ width: 0 }}
442
- animate={{ width: `${pct}%` }}
443
- className="h-full bg-[#c8860a] rounded-full group-hover/bar:bg-[#9a6307] transition-colors"
444
- />
 
 
 
 
 
 
 
445
  </div>
446
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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';