Spaces:
Running
Running
Commit Β·
d2c65ec
1
Parent(s): 351a621
feat: migrate jina v5, refine AI popup, add reference popup UI
Browse files- .gitignore +5 -0
- webapp/tipitaka-api/app/routers/health.py +46 -7
- webapp/tipitaka-api/app/services/page_service.py +184 -7
- webapp/tipitaka-web/src/components/ai/AIPopup.tsx +26 -13
- webapp/tipitaka-web/src/components/layout/AppShell.tsx +29 -5
- webapp/tipitaka-web/src/components/layout/ReaderPanel.tsx +48 -24
- webapp/tipitaka-web/src/components/reader/ReferencePopup.tsx +81 -0
- webapp/tipitaka-web/src/index.css +108 -14
.gitignore
CHANGED
|
@@ -156,4 +156,9 @@ merge_corpus.py
|
|
| 156 |
migrate_clean_data.py
|
| 157 |
tipitaka_expert.py
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
|
|
|
| 156 |
migrate_clean_data.py
|
| 157 |
tipitaka_expert.py
|
| 158 |
|
| 159 |
+
# ββ Agent Data & Local DBs ββ
|
| 160 |
+
.serena/
|
| 161 |
+
webapp/.serena/
|
| 162 |
+
embedding_cache.db
|
| 163 |
+
source_texts.db
|
| 164 |
|
webapp/tipitaka-api/app/routers/health.py
CHANGED
|
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
|
| 15 |
router = APIRouter(prefix="/health", tags=["Health"])
|
| 16 |
|
| 17 |
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 18 |
-
STATE_FILE = "
|
| 19 |
|
| 20 |
# ββ Alert rules ββ
|
| 21 |
ALERT_COOLDOWN_SEC = 1800 # 30 ΰΈΰΈ²ΰΈΰΈ΅ ΰΉΰΈ‘ΰΉΰΉΰΈΰΉΰΈΰΈΰΉΰΈ³ΰΈΰΉΰΈ²ΰΈΰΈ±ΰΈΰΈ«ΰΈ²ΰΉΰΈΰΈ΄ΰΈ‘
|
|
@@ -79,16 +79,55 @@ def check_database(settings) -> dict:
|
|
| 79 |
|
| 80 |
|
| 81 |
def check_qdrant(settings) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
try:
|
| 83 |
-
import qdrant_client
|
| 84 |
p = _resolve(settings.QDRANT_PATH)
|
| 85 |
if not os.path.isdir(p):
|
| 86 |
return {"status": "error", "error": "Dir not found"}
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
except Exception as e:
|
| 93 |
return {"status": "error", "error": str(e)[:150]}
|
| 94 |
|
|
|
|
| 15 |
router = APIRouter(prefix="/health", tags=["Health"])
|
| 16 |
|
| 17 |
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 18 |
+
STATE_FILE = "tipitaka_health_state.json" # Relative to project root/CWD
|
| 19 |
|
| 20 |
# ββ Alert rules ββ
|
| 21 |
ALERT_COOLDOWN_SEC = 1800 # 30 ΰΈΰΈ²ΰΈΰΈ΅ ΰΉΰΈ‘ΰΉΰΉΰΈΰΉΰΈΰΈΰΉΰΈ³ΰΈΰΉΰΈ²ΰΈΰΈ±ΰΈΰΈ«ΰΈ²ΰΉΰΈΰΈ΄ΰΈ‘
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def check_qdrant(settings) -> dict:
|
| 82 |
+
"""
|
| 83 |
+
Filesystem-only Qdrant health check β does NOT open a new QdrantClient.
|
| 84 |
+
Prevents 'already accessed by another instance' lock conflicts
|
| 85 |
+
since RAGService already holds an open Local Mode client.
|
| 86 |
+
"""
|
| 87 |
try:
|
|
|
|
| 88 |
p = _resolve(settings.QDRANT_PATH)
|
| 89 |
if not os.path.isdir(p):
|
| 90 |
return {"status": "error", "error": "Dir not found"}
|
| 91 |
+
|
| 92 |
+
# Try server mode first (auto-detect)
|
| 93 |
+
qdrant_url = getattr(settings, "QDRANT_URL", None)
|
| 94 |
+
if not qdrant_url:
|
| 95 |
+
try:
|
| 96 |
+
import httpx
|
| 97 |
+
r = httpx.get("http://localhost:6333/healthz", timeout=0.5)
|
| 98 |
+
if r.status_code == 200:
|
| 99 |
+
qdrant_url = "http://localhost:6333"
|
| 100 |
+
except Exception:
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
if qdrant_url:
|
| 104 |
+
try:
|
| 105 |
+
import httpx
|
| 106 |
+
resp = httpx.get(f"{qdrant_url}/collections", timeout=1.0)
|
| 107 |
+
if resp.status_code == 200:
|
| 108 |
+
data = resp.json()
|
| 109 |
+
cols = [c["name"] for c in data.get("result", {}).get("collections", [])]
|
| 110 |
+
has_chunks = any(c.startswith("tipitaka_chunks") for c in cols)
|
| 111 |
+
if has_chunks:
|
| 112 |
+
return {"status": "ok", "collections": cols}
|
| 113 |
+
return {"status": "degraded", "message": "Collection missing"}
|
| 114 |
+
return {"status": "error", "error": f"Server returned {resp.status_code}"}
|
| 115 |
+
except Exception as e:
|
| 116 |
+
return {"status": "error", "error": str(e)[:150]}
|
| 117 |
+
|
| 118 |
+
# Filesystem check (Local Mode β safe, no client needed)
|
| 119 |
+
col_dir = Path(p) / "collections" / "tipitaka_chunks"
|
| 120 |
+
if col_dir.is_dir():
|
| 121 |
+
# Read segment count as a rough health indicator
|
| 122 |
+
segments = list(col_dir.glob("*/segments/*"))
|
| 123 |
+
snapshots = list(Path(p).glob("*/snapshot*"))
|
| 124 |
+
return {
|
| 125 |
+
"status": "ok",
|
| 126 |
+
"mode": "local",
|
| 127 |
+
"collections_hint": ["tipitaka_chunks"],
|
| 128 |
+
"segments": len(segments) // 2,
|
| 129 |
+
}
|
| 130 |
+
return {"status": "degraded", "message": "Collection directory missing"}
|
| 131 |
except Exception as e:
|
| 132 |
return {"status": "error", "error": str(e)[:150]}
|
| 133 |
|
webapp/tipitaka-api/app/services/page_service.py
CHANGED
|
@@ -36,9 +36,34 @@ class PageService:
|
|
| 36 |
return f'<h4 class="content-heading">{text}</h4>'
|
| 37 |
html = re.sub(r'<B>(.*?)</B>', _replace_b, html, flags=re.IGNORECASE | re.DOTALL)
|
| 38 |
# Remove footnote markers: [ΰΉ], [ΰΉΰΉ], [ΰΈ], [ΰΈ] etc
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# Remove inline footnote refs like ΰΉ- (only when preceded by non-space)
|
| 43 |
html = re.sub(r'(?<=[^\s])[\u0E50-\u0E59]+-', '', html)
|
| 44 |
# Remove leading item markers: ΰΉ- at line start, [ΰΈ] etc
|
|
@@ -49,10 +74,162 @@ class PageService:
|
|
| 49 |
# Convert <I> to <i>, <U> to <u>
|
| 50 |
html = re.sub(r'<I>(.*?)</I>', r'<i>\1</i>', html, flags=re.IGNORECASE | re.DOTALL)
|
| 51 |
html = re.sub(r'<U>(.*?)</U>', r'<u>\1</u>', html, flags=re.IGNORECASE | re.DOTALL)
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
def get_page(self, volume_number: int, page_number: int):
|
| 58 |
with self.db.get_connection() as conn:
|
|
|
|
| 36 |
return f'<h4 class="content-heading">{text}</h4>'
|
| 37 |
html = re.sub(r'<B>(.*?)</B>', _replace_b, html, flags=re.IGNORECASE | re.DOTALL)
|
| 38 |
# Remove footnote markers: [ΰΉ], [ΰΉΰΉ], [ΰΈ], [ΰΈ] etc
|
| 39 |
+
# Use simpler matching for markers that are NOT at the start of a line
|
| 40 |
+
def _strip_footnotes(html_str):
|
| 41 |
+
lines = html_str.split('\n')
|
| 42 |
+
cleaned_lines = []
|
| 43 |
+
for line in lines:
|
| 44 |
+
if not line.strip():
|
| 45 |
+
cleaned_lines.append(line)
|
| 46 |
+
continue
|
| 47 |
+
# If line starts with a marker, protect it, then strip others
|
| 48 |
+
# Pattern for start of line markers
|
| 49 |
+
start_marker = re.match(r'^(\[[\u0E50-\u0E59]+\]|\[[\u0E01-\u0E39]+\]|\[\d+\])', line)
|
| 50 |
+
if start_marker:
|
| 51 |
+
marker = start_marker.group(1)
|
| 52 |
+
rest = line[len(marker):]
|
| 53 |
+
# Strip markers from the rest
|
| 54 |
+
rest = re.sub(r'\[[\u0E50-\u0E59]+\]', '', rest)
|
| 55 |
+
rest = re.sub(r'\[[\u0E01-\u0E39]+\]', '', rest)
|
| 56 |
+
rest = re.sub(r'\[\d+\]', '', rest)
|
| 57 |
+
cleaned_lines.append(marker + rest)
|
| 58 |
+
else:
|
| 59 |
+
line = re.sub(r'\[[\u0E50-\u0E59]+\]', '', line)
|
| 60 |
+
line = re.sub(r'\[[\u0E01-\u0E39]+\]', '', line)
|
| 61 |
+
line = re.sub(r'\[\d+\]', '', line)
|
| 62 |
+
cleaned_lines.append(line)
|
| 63 |
+
return '\n'.join(cleaned_lines)
|
| 64 |
+
|
| 65 |
+
html = _strip_footnotes(html)
|
| 66 |
+
|
| 67 |
# Remove inline footnote refs like ΰΉ- (only when preceded by non-space)
|
| 68 |
html = re.sub(r'(?<=[^\s])[\u0E50-\u0E59]+-', '', html)
|
| 69 |
# Remove leading item markers: ΰΉ- at line start, [ΰΈ] etc
|
|
|
|
| 74 |
# Convert <I> to <i>, <U> to <u>
|
| 75 |
html = re.sub(r'<I>(.*?)</I>', r'<i>\1</i>', html, flags=re.IGNORECASE | re.DOTALL)
|
| 76 |
html = re.sub(r'<U>(.*?)</U>', r'<u>\1</u>', html, flags=re.IGNORECASE | re.DOTALL)
|
| 77 |
+
|
| 78 |
+
# ββ Marker Normalization ββ
|
| 79 |
+
# Ensure markers like (ΰΉ), ΰΈΰΈ²ΰΈ‘., ΰΈΰΈΰΈ. start on a new line if they are currently inline
|
| 80 |
+
# NOTE: Exclude '(' from the preceding char group β otherwise (ΰΉ) gets split into ( + \nΰΉ)
|
| 81 |
+
marker_regex = r'(\([\u0E50-\u0E59]+\)|\[[\u0E50-\u0E59]+\]|[\u0E50-\u0E59]+\.|ΰΈΰΈ²ΰΈ‘\.|ΰΈΰΈΰΈ\.|\[\d+\]|\d+\.|\(\d+\)|\d+\))'
|
| 82 |
+
html = re.sub(r'([^\s\n\r>(\[])\s*' + marker_regex, r'\1\n\2', html)
|
| 83 |
+
|
| 84 |
+
# ββ Semantic Paragraph Wrapping ββ
|
| 85 |
+
# Split by existing block elements (h4) to wrap text between them
|
| 86 |
+
parts = re.split(r'(<h4.*?/h4>)', html, flags=re.DOTALL)
|
| 87 |
+
wrapped_parts = []
|
| 88 |
+
|
| 89 |
+
# Pattern 1: Section-heading markers that START a line AND have content after them
|
| 90 |
+
# e.g. ΰΉ. ΰΈΰΈΉΰΉΰΈ‘ΰΈ΅ΰΈ§ΰΈ΄ΰΈΰΈΰΈ²... ΰΈΰΈ²ΰΈ‘. / ΰΈΰΈ²ΰΈ‘ : / ΰΈΰΈΰΈ : β must have text following the marker
|
| 91 |
+
# Also includes parenthetical Thai numerals: (ΰΉ), (ΰΉ), (ΰΉΰΉ) etc.
|
| 92 |
+
num_pattern = r'^\s*([\u0E50-\u0E59]+[\.\)]|\([\u0E50-\u0E59]+\)|ΰΈΰΈ²ΰΈ‘\s*[\.\:]|ΰΈΰΈΰΈ\s*[\.\:]|\d+[\.\)]|\(\d+\))\s+\S'
|
| 93 |
+
|
| 94 |
+
# Pattern 2: Standalone footnote/endnote reference markers β MUST be fully bracketed
|
| 95 |
+
# e.g. (ΰΉ) [ΰΉ] (1) β bare digit) is NOT included because it may be a split tail
|
| 96 |
+
# of a longer expression like (ΰΉΰΈ£ΰΈ·ΰΉΰΈΰΈΰΈΰΈ΅ΰΉ ΰΉ) broken across two raw lines.
|
| 97 |
+
footnote_pattern = r'^\s*(\([\u0E50-\u0E59]+\)|\[[\u0E50-\u0E59]+\]|\(\d+\)|\[\d+\])\s*$'
|
| 98 |
+
|
| 99 |
+
for part in parts:
|
| 100 |
+
if part.startswith('<h4'):
|
| 101 |
+
wrapped_parts.append(part)
|
| 102 |
+
else:
|
| 103 |
+
# Split text into lines/paragraphs
|
| 104 |
+
raw_lines = [line.strip() for line in part.split('\n') if line.strip()]
|
| 105 |
+
|
| 106 |
+
# Merge split lines:
|
| 107 |
+
# Case A: lone '(' or '[' split from its marker, e.g. ['(', 'ΰΉ) text'] β ['(ΰΉ) text']
|
| 108 |
+
# Case B: previous line ends with unclosed '(' and current line is digits+)
|
| 109 |
+
# e.g. ['... (ΰΉΰΈ£ΰΈ·ΰΉΰΈΰΈΰΈΰΈ΅ΰΉ', 'ΰΉ)'] β ['... (ΰΉΰΈ£ΰΈ·ΰΉΰΈΰΈΰΈΰΈ΅ΰΉ ΰΉ)']
|
| 110 |
+
merged_lines = []
|
| 111 |
+
i = 0
|
| 112 |
+
while i < len(raw_lines):
|
| 113 |
+
line = raw_lines[i]
|
| 114 |
+
if line in ('(', '[') and i + 1 < len(raw_lines):
|
| 115 |
+
# Case A: lone bracket split from its marker
|
| 116 |
+
merged_lines.append(line + raw_lines[i + 1])
|
| 117 |
+
i += 2
|
| 118 |
+
elif (
|
| 119 |
+
merged_lines
|
| 120 |
+
and re.search(r'\([^)]*$', merged_lines[-1]) # previous line has unclosed (
|
| 121 |
+
and re.match(r'^[\u0E50-\u0E59\d]+[\)\]]\s*$', line) # current is digits)
|
| 122 |
+
):
|
| 123 |
+
# Case B β rejoin as continuation, not a new line
|
| 124 |
+
merged_lines[-1] = merged_lines[-1] + line
|
| 125 |
+
i += 1
|
| 126 |
+
elif (
|
| 127 |
+
re.match(r'^[\u0E50-\u0E59]+$', line) # Case C: only Thai digits, no . or )
|
| 128 |
+
and i + 1 < len(raw_lines)
|
| 129 |
+
and re.match(r'^[\u0E50-\u0E59]+[\.\)]', raw_lines[i + 1]) # next starts digit + . or )
|
| 130 |
+
):
|
| 131 |
+
# e.g. DB row "ΰΉ" + next row "ΰΉ. ΰΈ«ΰΈ±ΰΈ§ΰΈΰΉΰΈ" β "ΰΉΰΉ. ΰΈ«ΰΈ±ΰΈ§ΰΈΰΉΰΈ"
|
| 132 |
+
merged_lines.append(line + raw_lines[i + 1])
|
| 133 |
+
i += 2
|
| 134 |
+
else:
|
| 135 |
+
merged_lines.append(line)
|
| 136 |
+
i += 1
|
| 137 |
+
|
| 138 |
+
# Accumulate consecutive body lines into single <p> elements.
|
| 139 |
+
# Each DB line is a raw manuscript line β multiple DB lines form one
|
| 140 |
+
# semantic paragraph. Grouping them lets text-align:justify work
|
| 141 |
+
# (justify only has effect on lines that aren't the last line of a <p>).
|
| 142 |
+
para_buf = [] # buffered body lines for the current paragraph
|
| 143 |
+
|
| 144 |
+
# Option B: length threshold β short merged content = left-align item,
|
| 145 |
+
# long merged content = paragraph (justify applies meaningfully).
|
| 146 |
+
PARA_MIN_CHARS = 80
|
| 147 |
+
|
| 148 |
+
# Pattern: standalone (ΰΈ’ΰΉΰΈ) markers in all forms:
|
| 149 |
+
# (ΰΈ’ΰΉΰΈ) / ΰΉ (ΰΈ’ΰΉΰΈ) / (ΰΉ) (ΰΈ’ΰΉΰΈ)
|
| 150 |
+
abbrev_pattern = r'^\s*([\u0E50-\u0E59]+|\([\u0E50-\u0E59]+\))?\s*\(ΰΈ’ΰΉΰΈ\)\s*$'
|
| 151 |
+
|
| 152 |
+
def apply_inline_refs(text: str) -> str:
|
| 153 |
+
"""Wrap inline (Thai_digit) and (ΰΈ’ΰΉΰΈ) references as superscript."""
|
| 154 |
+
text = re.sub(
|
| 155 |
+
r'(?<=\S)\s*\(([\u0E50-\u0E59]+|\d+)\)',
|
| 156 |
+
r'<sup class="footnote-ref">(\1)</sup>',
|
| 157 |
+
text
|
| 158 |
+
)
|
| 159 |
+
text = re.sub(
|
| 160 |
+
r'\s*\(ΰΈ’ΰΉΰΈ\)',
|
| 161 |
+
r' <sup class="abbrev-ref">(ΰΈ’ΰΉΰΈ)</sup>',
|
| 162 |
+
text
|
| 163 |
+
)
|
| 164 |
+
return text
|
| 165 |
+
|
| 166 |
+
def flush_para():
|
| 167 |
+
"""Output buffered lines as a <p>. Short content β left-align."""
|
| 168 |
+
if para_buf:
|
| 169 |
+
text = apply_inline_refs(' '.join(para_buf))
|
| 170 |
+
if len(text) >= PARA_MIN_CHARS:
|
| 171 |
+
wrapped_parts.append(f'<p>{text}</p>')
|
| 172 |
+
else:
|
| 173 |
+
wrapped_parts.append(f'<p class="structured-line">{text}</p>')
|
| 174 |
+
para_buf.clear()
|
| 175 |
+
|
| 176 |
+
for line in merged_lines:
|
| 177 |
+
if re.match(footnote_pattern, line):
|
| 178 |
+
flush_para()
|
| 179 |
+
marker = line.strip()
|
| 180 |
+
# Attach inline to the previous <p> so it doesn't create a gap
|
| 181 |
+
if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
|
| 182 |
+
wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' <sup class="footnote-ref">{marker}</sup></p>'
|
| 183 |
+
else:
|
| 184 |
+
wrapped_parts.append(f'<p class="footnote-ref">{marker}</p>')
|
| 185 |
+
elif re.match(abbrev_pattern, line):
|
| 186 |
+
# (ΰΈ’ΰΉΰΈ) / ΰΉ (ΰΈ’ΰΉΰΈ) / (ΰΉ) (ΰΈ’ΰΉΰΈ) β split into separate sups:
|
| 187 |
+
# number part β footnote-ref, (ΰΈ’ΰΉΰΈ) β abbrev-ref
|
| 188 |
+
flush_para()
|
| 189 |
+
m = re.match(
|
| 190 |
+
r'^\s*([\u0E50-\u0E59]+|\([\u0E50-\u0E59]+\))?\s*(\(ΰΈ’ΰΉΰΈ\))\s*$',
|
| 191 |
+
line.strip()
|
| 192 |
+
)
|
| 193 |
+
if m and m.group(1):
|
| 194 |
+
sup_html = (
|
| 195 |
+
f'<sup class="footnote-ref">{m.group(1)}</sup>'
|
| 196 |
+
f'\u202f<sup class="abbrev-ref">{m.group(2)}</sup>'
|
| 197 |
+
)
|
| 198 |
+
else:
|
| 199 |
+
sup_html = f'<sup class="abbrev-ref">(ΰΈ’ΰΉΰΈ)</sup>'
|
| 200 |
+
if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
|
| 201 |
+
wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' {sup_html}</p>'
|
| 202 |
+
else:
|
| 203 |
+
wrapped_parts.append(sup_html)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
elif line.startswith('...'):
|
| 207 |
+
# Continuation / repetition marker in Patthana β no paragraph indent
|
| 208 |
+
flush_para()
|
| 209 |
+
formatted_line = apply_inline_refs(line)
|
| 210 |
+
wrapped_parts.append(f'<p class="continuation-line">{formatted_line}</p>')
|
| 211 |
+
|
| 212 |
+
elif re.match(num_pattern, line):
|
| 213 |
+
flush_para()
|
| 214 |
+
wrapped_parts.append(f'<p class="numbered-line">{line}</p>')
|
| 215 |
+
elif re.search(r'[ \t]{4,}', line):
|
| 216 |
+
# Structural line β large internal spaces (tabular data)
|
| 217 |
+
flush_para()
|
| 218 |
+
clean = re.sub(r'[ \t]{2,}', ' ', line).strip()
|
| 219 |
+
wrapped_parts.append(f'<p class="structured-line">{clean}</p>')
|
| 220 |
+
elif re.search(r'(ΰΈ‘ΰΈ΅\s*[ΰΉ-ΰΉ\d]+\s*ΰΈ§ΰΈ²ΰΈ£ΰΈ°|\(ΰΈ’ΰΉΰΈ\)|\(ΰΈΰΈ’ΰΉΰΈ\)|ΰΈ―ΰΈ₯ΰΈ―)\s*$', line):
|
| 221 |
+
# Patthana/Abhidhamma terminal β flush trigger, then length decides class
|
| 222 |
+
para_buf.append(line)
|
| 223 |
+
flush_para()
|
| 224 |
+
else:
|
| 225 |
+
# Regular body text β accumulate into current paragraph
|
| 226 |
+
para_buf.append(line)
|
| 227 |
+
|
| 228 |
+
flush_para() # flush any remaining lines at end of section
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
return "".join(wrapped_parts)
|
| 232 |
+
|
| 233 |
|
| 234 |
def get_page(self, volume_number: int, page_number: int):
|
| 235 |
with self.db.get_connection() as conn:
|
webapp/tipitaka-web/src/components/ai/AIPopup.tsx
CHANGED
|
@@ -338,15 +338,24 @@ const AIPopup: React.FC = () => {
|
|
| 338 |
)}
|
| 339 |
{/* βββββββββββ Compact Header + Drag Handle βββββββββββ */}
|
| 340 |
<div
|
| 341 |
-
className="flex flex-col flex-shrink-0
|
| 342 |
onMouseDown={handleMouseDown}
|
| 343 |
style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
|
| 344 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
{/* Row 1: Title + actions */}
|
| 346 |
-
<div className="flex items-center justify-between px-3 py-2">
|
| 347 |
-
<div className="flex items-center gap-2 min-w-0">
|
| 348 |
-
<
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
</div>
|
| 351 |
<div className="flex items-center gap-1 flex-shrink-0">
|
| 352 |
<div className="flex bg-black/15 rounded-lg p-0.5 mr-1">
|
|
@@ -506,14 +515,18 @@ const AIPopup: React.FC = () => {
|
|
| 506 |
</div>
|
| 507 |
|
| 508 |
{/* βββββββββββ Input βββββββββββ */}
|
| 509 |
-
<form onSubmit={handleSubmit} className={`p-
|
| 510 |
-
<
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
</form>
|
| 518 |
</motion.div>
|
| 519 |
</>
|
|
|
|
| 338 |
)}
|
| 339 |
{/* βββββββββββ Compact Header + Drag Handle βββββββββββ */}
|
| 340 |
<div
|
| 341 |
+
className="flex flex-col flex-shrink-0 relative overflow-hidden"
|
| 342 |
onMouseDown={handleMouseDown}
|
| 343 |
style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
|
| 344 |
>
|
| 345 |
+
{/* Glass background layer */}
|
| 346 |
+
<div className="absolute inset-0 bg-[#c8860a] opacity-90" />
|
| 347 |
+
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent pointer-events-none" />
|
| 348 |
+
|
| 349 |
{/* Row 1: Title + actions */}
|
| 350 |
+
<div className="relative flex items-center justify-between px-3 py-2.5 z-10">
|
| 351 |
+
<div className="flex items-center gap-2.5 min-w-0">
|
| 352 |
+
<div className="w-7 h-7 rounded-lg bg-white/20 flex items-center justify-center shadow-inner">
|
| 353 |
+
<Sparkles size={16} className="text-white" />
|
| 354 |
+
</div>
|
| 355 |
+
<div>
|
| 356 |
+
<h3 className="font-bold text-sm tracking-wide text-white">ΰΈΰΈΉΰΉΰΈΰΉΰΈ§ΰΈ’ AI</h3>
|
| 357 |
+
<p className="text-[9px] text-white/70 font-medium uppercase tracking-tighter">Dhamma Assistant</p>
|
| 358 |
+
</div>
|
| 359 |
</div>
|
| 360 |
<div className="flex items-center gap-1 flex-shrink-0">
|
| 361 |
<div className="flex bg-black/15 rounded-lg p-0.5 mr-1">
|
|
|
|
| 515 |
</div>
|
| 516 |
|
| 517 |
{/* βββββββββββ Input βββββββββββ */}
|
| 518 |
+
<form onSubmit={handleSubmit} className={`p-3 md:p-4 border-t ${s.border} flex gap-2 flex-shrink-0 ${s.bg}`}>
|
| 519 |
+
<div className="relative flex-1">
|
| 520 |
+
<input type="text" value={input} onChange={(e) => setInput(e.target.value)}
|
| 521 |
+
placeholder="ΰΈΰΈ²ΰΈ‘ΰΉΰΈΰΈ΅ΰΉΰΈ’ΰΈ§ΰΈΰΈ±ΰΈΰΈΰΈ£ΰΈ°ΰΈΰΈ£ΰΈ£ΰΈ‘ΰΉΰΈΰΈ«ΰΈΰΉΰΈ²ΰΈΰΈ΅ΰΉ..." disabled={isStreaming}
|
| 522 |
+
className={`w-full pl-4 pr-12 py-2.5 rounded-2xl outline-none text-sm disabled:opacity-50 border shadow-inner transition-all ${s.border} ${s.msgBg} ${s.text} focus:border-[#c8860a] focus:ring-2 focus:ring-[#c8860a]/20`}
|
| 523 |
+
/>
|
| 524 |
+
<button type="submit" disabled={!input.trim() || isStreaming}
|
| 525 |
+
className="absolute right-1.5 top-1.5 p-2 bg-[#c8860a] text-white rounded-xl disabled:opacity-50 hover:bg-[#9a6307] transition-all shadow-md active:scale-95"
|
| 526 |
+
>
|
| 527 |
+
<Send size={14} />
|
| 528 |
+
</button>
|
| 529 |
+
</div>
|
| 530 |
</form>
|
| 531 |
</motion.div>
|
| 532 |
</>
|
webapp/tipitaka-web/src/components/layout/AppShell.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
import NavDrawer from './NavDrawer';
|
| 3 |
import ReaderPanel from './ReaderPanel';
|
| 4 |
import RightToolbar from '../toolbar/RightToolbar';
|
|
@@ -7,14 +7,14 @@ import AIPopup from '../ai/AIPopup';
|
|
| 7 |
import { Menu } from 'lucide-react';
|
| 8 |
import { useUIStore, useThemeStore } from '../../stores/appStore';
|
| 9 |
import { useAIStore } from '../../stores/aiStore';
|
| 10 |
-
import { AnimatePresence } from 'framer-motion';
|
| 11 |
import { useKeyboardNav } from '../../hooks/useKeyboardNav';
|
| 12 |
|
| 13 |
// Shell = NavDrawer bg per theme β seamless sidebar integration
|
| 14 |
const SHELL_BG: Record<string, string> = {
|
| 15 |
-
dark: '
|
| 16 |
-
light: '
|
| 17 |
-
classic: '
|
| 18 |
};
|
| 19 |
|
| 20 |
// Menu button bg per theme (matches RightToolbar bg for visual consistency)
|
|
@@ -35,6 +35,20 @@ const AppShell: React.FC = () => {
|
|
| 35 |
const shellBg = SHELL_BG[theme] ?? SHELL_BG.dark;
|
| 36 |
const menuBtn = MENU_BTN[theme] ?? MENU_BTN.dark;
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
return (
|
| 39 |
<div className={`flex h-screen overflow-hidden ${shellBg}`}>
|
| 40 |
{/* Left: NavDrawer */}
|
|
@@ -42,6 +56,16 @@ const AppShell: React.FC = () => {
|
|
| 42 |
|
| 43 |
{/* Centre: Reader β right-padded to clear RightToolbar (44px) on desktop */}
|
| 44 |
<div className="flex-1 flex flex-col relative overflow-hidden lg:pr-11">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
{/* Menu button β visible only when drawer is closed */}
|
| 46 |
{!isNavOpen && (
|
| 47 |
<button
|
|
|
|
| 1 |
+
import React, { useEffect } from 'react';
|
| 2 |
import NavDrawer from './NavDrawer';
|
| 3 |
import ReaderPanel from './ReaderPanel';
|
| 4 |
import RightToolbar from '../toolbar/RightToolbar';
|
|
|
|
| 7 |
import { Menu } from 'lucide-react';
|
| 8 |
import { useUIStore, useThemeStore } from '../../stores/appStore';
|
| 9 |
import { useAIStore } from '../../stores/aiStore';
|
| 10 |
+
import { AnimatePresence, motion } from 'framer-motion';
|
| 11 |
import { useKeyboardNav } from '../../hooks/useKeyboardNav';
|
| 12 |
|
| 13 |
// Shell = NavDrawer bg per theme β seamless sidebar integration
|
| 14 |
const SHELL_BG: Record<string, string> = {
|
| 15 |
+
dark: 'theme-dark-bg',
|
| 16 |
+
light: 'theme-light-bg',
|
| 17 |
+
classic: 'theme-classic-bg',
|
| 18 |
};
|
| 19 |
|
| 20 |
// Menu button bg per theme (matches RightToolbar bg for visual consistency)
|
|
|
|
| 35 |
const shellBg = SHELL_BG[theme] ?? SHELL_BG.dark;
|
| 36 |
const menuBtn = MENU_BTN[theme] ?? MENU_BTN.dark;
|
| 37 |
|
| 38 |
+
// ββ Scroll Progress ββββββββββββββββββββββββββββββββββββββββ
|
| 39 |
+
const [scrollProgress, setScrollProgress] = React.useState(0);
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
const el = document.getElementById('reader-scroll-container');
|
| 42 |
+
if (!el) return;
|
| 43 |
+
const handleScroll = () => {
|
| 44 |
+
const total = el.scrollHeight - el.clientHeight;
|
| 45 |
+
if (total <= 0) { setScrollProgress(0); return; }
|
| 46 |
+
setScrollProgress((el.scrollTop / total) * 100);
|
| 47 |
+
};
|
| 48 |
+
el.addEventListener('scroll', handleScroll);
|
| 49 |
+
return () => el.removeEventListener('scroll', handleScroll);
|
| 50 |
+
}, []);
|
| 51 |
+
|
| 52 |
return (
|
| 53 |
<div className={`flex h-screen overflow-hidden ${shellBg}`}>
|
| 54 |
{/* Left: NavDrawer */}
|
|
|
|
| 56 |
|
| 57 |
{/* Centre: Reader β right-padded to clear RightToolbar (44px) on desktop */}
|
| 58 |
<div className="flex-1 flex flex-col relative overflow-hidden lg:pr-11">
|
| 59 |
+
{/* Scroll Progress Bar */}
|
| 60 |
+
<div className="absolute top-0 left-0 right-0 h-0.5 z-40 bg-white/5">
|
| 61 |
+
<motion.div
|
| 62 |
+
className="h-full bg-[#c8860a]"
|
| 63 |
+
initial={{ width: 0 }}
|
| 64 |
+
animate={{ width: `${scrollProgress}%` }}
|
| 65 |
+
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
| 66 |
+
/>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
{/* Menu button β visible only when drawer is closed */}
|
| 70 |
{!isNavOpen && (
|
| 71 |
<button
|
webapp/tipitaka-web/src/components/layout/ReaderPanel.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useReaderStore, useThemeStore } from '../../stores/appStore';
|
|
| 3 |
import api from '../../lib/api';
|
| 4 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
import SelectionPopup from '../reader/SelectionPopup';
|
|
|
|
| 6 |
import { useSwipeNav } from '../../hooks/useSwipeNav';
|
| 7 |
|
| 8 |
const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
|
|
@@ -12,20 +13,17 @@ const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
|
|
| 12 |
};
|
| 13 |
|
| 14 |
const SHELL_STYLES: Record<string, string> = {
|
| 15 |
-
dark: '
|
| 16 |
-
light: '
|
| 17 |
-
classic: '
|
| 18 |
};
|
| 19 |
|
| 20 |
const renderContent = (html: string, fontSize: number, endMarkers: string[]) => {
|
| 21 |
return (
|
| 22 |
-
<div
|
| 23 |
-
style={{ fontSize: `${fontSize}px`, lineHeight: '2.1' }}
|
| 24 |
-
className="reader-content text-center space-y-3"
|
| 25 |
-
>
|
| 26 |
{html ? (
|
| 27 |
<div
|
| 28 |
-
className="content-body
|
| 29 |
dangerouslySetInnerHTML={{ __html: html }}
|
| 30 |
/>
|
| 31 |
) : (
|
|
@@ -35,9 +33,13 @@ const renderContent = (html: string, fontSize: number, endMarkers: string[]) =>
|
|
| 35 |
{endMarkers.length > 0 && (
|
| 36 |
<div className="space-y-1 pt-2">
|
| 37 |
{endMarkers.map((title, i) => (
|
| 38 |
-
<
|
| 39 |
-
-------
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
))}
|
| 42 |
</div>
|
| 43 |
)}
|
|
@@ -69,29 +71,30 @@ const renderFirstPage = (html: string, fontSize: number, volumeTitle: string) =>
|
|
| 69 |
|
| 70 |
return (
|
| 71 |
<div
|
| 72 |
-
style={{ fontSize: `${fontSize}px`
|
| 73 |
-
className="text-center space-y-3"
|
| 74 |
>
|
| 75 |
-
{/* Volume title β centered */}
|
| 76 |
-
<
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
{line2 && (
|
| 80 |
-
<p className="text-base md:text-lg text-[#c8860a] leading-tight">
|
| 81 |
-
{line2}
|
| 82 |
</p>
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
{html ? (
|
| 86 |
<>
|
| 87 |
{/* Top: everything before/at homage β centered, clean */}
|
| 88 |
<div
|
| 89 |
-
className="first-page-top
|
| 90 |
dangerouslySetInnerHTML={{ __html: topPart }}
|
| 91 |
/>
|
| 92 |
{/* Bottom: after homage β normal h4 styling, text-justify */}
|
| 93 |
<div
|
| 94 |
-
className="content-body
|
| 95 |
dangerouslySetInnerHTML={{ __html: bottomPart }}
|
| 96 |
/>
|
| 97 |
</>
|
|
@@ -112,6 +115,7 @@ const ReaderPanel: React.FC = () => {
|
|
| 112 |
const { theme, fontSize } = useThemeStore();
|
| 113 |
const [content, setContent] = React.useState<any>(null);
|
| 114 |
const [loading, setLoading] = React.useState(true);
|
|
|
|
| 115 |
const prevPageRef = useRef(currentPage);
|
| 116 |
|
| 117 |
const swipeRef = useSwipeNav<HTMLDivElement>();
|
|
@@ -136,9 +140,28 @@ const ReaderPanel: React.FC = () => {
|
|
| 136 |
const { reader: readerCls } = THEME_STYLES[theme] ?? THEME_STYLES.dark;
|
| 137 |
const shellCls = SHELL_STYLES[theme] ?? SHELL_STYLES.dark;
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
return (
|
| 140 |
<div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
|
| 141 |
<SelectionPopup />
|
|
|
|
| 142 |
<div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
|
| 143 |
<AnimatePresence mode="wait">
|
| 144 |
{loading ? (
|
|
@@ -157,6 +180,7 @@ const ReaderPanel: React.FC = () => {
|
|
| 157 |
initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
|
| 158 |
transition={{ duration: 0.35, ease: 'easeOut' }}
|
| 159 |
className={`rounded-xl p-8 lg:p-12 ${readerCls}`}
|
|
|
|
| 160 |
>
|
| 161 |
{currentPage === 1 || content?.page_number === 1 ? (
|
| 162 |
renderFirstPage(
|
|
|
|
| 3 |
import api from '../../lib/api';
|
| 4 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
import SelectionPopup from '../reader/SelectionPopup';
|
| 6 |
+
import ReferencePopup, { type RefPopupPos } from '../reader/ReferencePopup';
|
| 7 |
import { useSwipeNav } from '../../hooks/useSwipeNav';
|
| 8 |
|
| 9 |
const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
|
|
|
|
| 13 |
};
|
| 14 |
|
| 15 |
const SHELL_STYLES: Record<string, string> = {
|
| 16 |
+
dark: 'theme-dark-bg',
|
| 17 |
+
light: 'theme-light-bg',
|
| 18 |
+
classic: 'theme-classic-bg',
|
| 19 |
};
|
| 20 |
|
| 21 |
const renderContent = (html: string, fontSize: number, endMarkers: string[]) => {
|
| 22 |
return (
|
| 23 |
+
<div style={{ fontSize: `${fontSize}px` }}>
|
|
|
|
|
|
|
|
|
|
| 24 |
{html ? (
|
| 25 |
<div
|
| 26 |
+
className="content-body reader-content"
|
| 27 |
dangerouslySetInnerHTML={{ __html: html }}
|
| 28 |
/>
|
| 29 |
) : (
|
|
|
|
| 33 |
{endMarkers.length > 0 && (
|
| 34 |
<div className="space-y-1 pt-2">
|
| 35 |
{endMarkers.map((title, i) => (
|
| 36 |
+
<div key={i} className="flex items-center gap-4 py-2 opacity-40">
|
| 37 |
+
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-[#888] to-transparent" />
|
| 38 |
+
<p className="text-[10px] text-[#888] tracking-[0.2em] uppercase font-medium">
|
| 39 |
+
{title}
|
| 40 |
+
</p>
|
| 41 |
+
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-[#888] to-transparent" />
|
| 42 |
+
</div>
|
| 43 |
))}
|
| 44 |
</div>
|
| 45 |
)}
|
|
|
|
| 71 |
|
| 72 |
return (
|
| 73 |
<div
|
| 74 |
+
style={{ fontSize: `${fontSize}px` }}
|
|
|
|
| 75 |
>
|
| 76 |
+
{/* Volume title β centered explicitly */}
|
| 77 |
+
<div className="text-center">
|
| 78 |
+
<p className="text-lg md:text-xl font-bold text-[#c8860a] leading-tight">
|
| 79 |
+
{line1}
|
|
|
|
|
|
|
|
|
|
| 80 |
</p>
|
| 81 |
+
{line2 && (
|
| 82 |
+
<p className="text-base md:text-lg text-[#c8860a] leading-tight">
|
| 83 |
+
{line2}
|
| 84 |
+
</p>
|
| 85 |
+
)}
|
| 86 |
+
<div className="border-b border-[#c8860a]/15 my-3" />
|
| 87 |
+
</div>
|
| 88 |
{html ? (
|
| 89 |
<>
|
| 90 |
{/* Top: everything before/at homage β centered, clean */}
|
| 91 |
<div
|
| 92 |
+
className="first-page-top text-center"
|
| 93 |
dangerouslySetInnerHTML={{ __html: topPart }}
|
| 94 |
/>
|
| 95 |
{/* Bottom: after homage β normal h4 styling, text-justify */}
|
| 96 |
<div
|
| 97 |
+
className="content-body reader-content"
|
| 98 |
dangerouslySetInnerHTML={{ __html: bottomPart }}
|
| 99 |
/>
|
| 100 |
</>
|
|
|
|
| 115 |
const { theme, fontSize } = useThemeStore();
|
| 116 |
const [content, setContent] = React.useState<any>(null);
|
| 117 |
const [loading, setLoading] = React.useState(true);
|
| 118 |
+
const [refPos, setRefPos] = React.useState<RefPopupPos | null>(null);
|
| 119 |
const prevPageRef = useRef(currentPage);
|
| 120 |
|
| 121 |
const swipeRef = useSwipeNav<HTMLDivElement>();
|
|
|
|
| 140 |
const { reader: readerCls } = THEME_STYLES[theme] ?? THEME_STYLES.dark;
|
| 141 |
const shellCls = SHELL_STYLES[theme] ?? SHELL_STYLES.dark;
|
| 142 |
|
| 143 |
+
const handleArticleClick = (e: React.MouseEvent) => {
|
| 144 |
+
const target = e.target as HTMLElement;
|
| 145 |
+
if (target.tagName.toLowerCase() === 'sup') {
|
| 146 |
+
const isAbbrev = target.classList.contains('abbrev-ref');
|
| 147 |
+
const isFootnote = target.classList.contains('footnote-ref');
|
| 148 |
+
|
| 149 |
+
if (isAbbrev || isFootnote) {
|
| 150 |
+
const rect = target.getBoundingClientRect();
|
| 151 |
+
setRefPos({
|
| 152 |
+
x: rect.left + rect.width / 2,
|
| 153 |
+
y: rect.bottom,
|
| 154 |
+
id: target.innerText,
|
| 155 |
+
type: isAbbrev ? 'abbrev' : 'footnote'
|
| 156 |
+
});
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
return (
|
| 162 |
<div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
|
| 163 |
<SelectionPopup />
|
| 164 |
+
<ReferencePopup pos={refPos} onClose={() => setRefPos(null)} />
|
| 165 |
<div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
|
| 166 |
<AnimatePresence mode="wait">
|
| 167 |
{loading ? (
|
|
|
|
| 180 |
initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
|
| 181 |
transition={{ duration: 0.35, ease: 'easeOut' }}
|
| 182 |
className={`rounded-xl p-8 lg:p-12 ${readerCls}`}
|
| 183 |
+
onClick={handleArticleClick}
|
| 184 |
>
|
| 185 |
{currentPage === 1 || content?.page_number === 1 ? (
|
| 186 |
renderFirstPage(
|
webapp/tipitaka-web/src/components/reader/ReferencePopup.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef } from 'react';
|
| 2 |
+
import { motion, AnimatePresence } from 'framer-motion';
|
| 3 |
+
|
| 4 |
+
export interface RefPopupPos {
|
| 5 |
+
x: number;
|
| 6 |
+
y: number;
|
| 7 |
+
id: string;
|
| 8 |
+
type: 'abbrev' | 'footnote';
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
pos: RefPopupPos | null;
|
| 13 |
+
onClose: () => void;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const ReferencePopup: React.FC<Props> = ({ pos, onClose }) => {
|
| 17 |
+
const popupRef = useRef<HTMLDivElement>(null);
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
// Click outside popup β close
|
| 21 |
+
const onMouseDown = (e: MouseEvent | TouchEvent) => {
|
| 22 |
+
// Don't close if they clicked on another reference (it will update pos)
|
| 23 |
+
const target = e.target as HTMLElement;
|
| 24 |
+
if (target.tagName?.toLowerCase() === 'sup' && (target.classList.contains('abbrev-ref') || target.classList.contains('footnote-ref'))) {
|
| 25 |
+
return;
|
| 26 |
+
}
|
| 27 |
+
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
|
| 28 |
+
onClose();
|
| 29 |
+
}
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
document.addEventListener('mousedown', onMouseDown);
|
| 33 |
+
document.addEventListener('touchstart', onMouseDown);
|
| 34 |
+
return () => {
|
| 35 |
+
document.removeEventListener('mousedown', onMouseDown);
|
| 36 |
+
document.removeEventListener('touchstart', onMouseDown);
|
| 37 |
+
};
|
| 38 |
+
}, [onClose]);
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<AnimatePresence>
|
| 42 |
+
{pos && (
|
| 43 |
+
<motion.div
|
| 44 |
+
ref={popupRef}
|
| 45 |
+
key="ref-popup"
|
| 46 |
+
initial={{ opacity: 0, scale: 0.95, y: 8 }}
|
| 47 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 48 |
+
exit={{ opacity: 0, scale: 0.95, y: 8 }}
|
| 49 |
+
transition={{ duration: 0.15, ease: 'easeOut' }}
|
| 50 |
+
style={{
|
| 51 |
+
position: 'fixed',
|
| 52 |
+
left: pos.x,
|
| 53 |
+
top: pos.y,
|
| 54 |
+
transform: 'translate(-50%, 8px)', // 8px gap below the target
|
| 55 |
+
zIndex: 9998,
|
| 56 |
+
}}
|
| 57 |
+
className="w-64 p-4 rounded-xl shadow-2xl bg-[#1a1a2e] border border-[#3a3a5e] text-[#e8e4da] text-sm leading-relaxed"
|
| 58 |
+
>
|
| 59 |
+
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-[#3a3a5e]">
|
| 60 |
+
<span className="text-[#c8860a] font-bold">{pos.id}</span>
|
| 61 |
+
<span className="text-xs text-[#888]">
|
| 62 |
+
{pos.type === 'abbrev' ? 'ΰΉΰΈΰΈ·ΰΉΰΈΰΈ«ΰΈ²ΰΈͺΰΉΰΈ§ΰΈΰΈΰΈ΅ΰΉΰΈ’ΰΉΰΈΰΉΰΈ§ΰΉ' : 'ΰΉΰΈΰΈ΄ΰΈΰΈΰΈ£ΰΈ£ΰΈ'}
|
| 63 |
+
</span>
|
| 64 |
+
</div>
|
| 65 |
+
<p className="opacity-80 text-xs">
|
| 66 |
+
{pos.type === 'abbrev'
|
| 67 |
+
? "ΰΈΰΈ³ΰΈ₯ΰΈ±ΰΈΰΈΰΈ’ΰΈΉΰΉΰΉΰΈΰΈ£ΰΈ°ΰΈ«ΰΈ§ΰΉΰΈ²ΰΈΰΈΰΈ²ΰΈ£ΰΈΰΈ±ΰΈΰΈΰΈ² ΰΉΰΈΰΈ·ΰΉΰΈΰΈΰΈΆΰΈΰΈΰΉΰΈΰΈ‘ΰΈΉΰΈ₯ΰΈͺΰΉΰΈ§ΰΈΰΈΰΈ΅ΰΉΰΈΰΈΉΰΈΰΈ’ΰΉΰΈΰΈ‘ΰΈ²ΰΉΰΈͺΰΈΰΈΰΈΰΈ₯..."
|
| 68 |
+
: "ΰΈΰΉΰΈΰΈ‘ΰΈΉΰΈ₯ΰΉΰΈΰΈ΄ΰΈΰΈΰΈ£ΰΈ£ΰΈΰΈΰΈ³ΰΈ₯ΰΈ±ΰΈΰΈΰΈ’ΰΈΉΰΉΰΉΰΈΰΈ£ΰΈ°ΰΈ«ΰΈ§ΰΉΰΈ²ΰΈΰΈΰΈ²ΰΈ£ΰΈΰΈ±ΰΈΰΈΰΈ²..."}
|
| 69 |
+
</p>
|
| 70 |
+
{/* tail arrow pointing up */}
|
| 71 |
+
<div
|
| 72 |
+
className="pointer-events-none absolute left-1/2 -translate-x-1/2 -top-[6px]
|
| 73 |
+
w-3 h-3 bg-[#1a1a2e] border-l border-t border-[#3a3a5e] rotate-45"
|
| 74 |
+
/>
|
| 75 |
+
</motion.div>
|
| 76 |
+
)}
|
| 77 |
+
</AnimatePresence>
|
| 78 |
+
);
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
export default ReferencePopup;
|
webapp/tipitaka-web/src/index.css
CHANGED
|
@@ -12,7 +12,7 @@
|
|
| 12 |
|
| 13 |
:root {
|
| 14 |
font-family: var(--font-sarabun);
|
| 15 |
-
line-height: 1.
|
| 16 |
font-weight: 400;
|
| 17 |
|
| 18 |
color: var(--color-pali-text);
|
|
@@ -38,22 +38,91 @@ body {
|
|
| 38 |
/* ββ Thai/Pali Typography ββββββββββββββββββββββββββββββββββββββββ */
|
| 39 |
|
| 40 |
/* Font-kerning and Thai OpenType features for the body reading text */
|
| 41 |
-
.reader-content p
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
text-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
font-kerning: normal;
|
| 50 |
-
/* OpenType features for Thai script rendering */
|
| 51 |
font-feature-settings: "kern" 1, "liga" 1, "clig" 1;
|
| 52 |
-
/* Balanced orphans/widows for paragraphs */
|
| 53 |
orphans: 2;
|
| 54 |
widows: 2;
|
| 55 |
}
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
/* Remove gap when <br/> is at the start of a <p> (edge case) */
|
| 58 |
.reader-content p:first-child {
|
| 59 |
margin-top: 0;
|
|
@@ -70,10 +139,35 @@ body {
|
|
| 70 |
border-image: linear-gradient(to bottom, #D4AF37, #F4E7A5, #D4AF37) 1;
|
| 71 |
}
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
.glass-panel {
|
| 74 |
-
background: rgba(255, 255, 255, 0.
|
| 75 |
-
backdrop-filter: blur(
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
/* ββ Inline Content Headings ββ smaller than body text, highlight makes it stand out */
|
|
|
|
| 12 |
|
| 13 |
:root {
|
| 14 |
font-family: var(--font-sarabun);
|
| 15 |
+
line-height: 1.7;
|
| 16 |
font-weight: 400;
|
| 17 |
|
| 18 |
color: var(--color-pali-text);
|
|
|
|
| 38 |
/* ββ Thai/Pali Typography ββββββββββββββββββββββββββββββββββββββββ */
|
| 39 |
|
| 40 |
/* Font-kerning and Thai OpenType features for the body reading text */
|
| 41 |
+
.reader-content p,
|
| 42 |
+
.content-body p {
|
| 43 |
+
/* !important overrides Tailwind typography plugin defaults and any inherited text-center */
|
| 44 |
+
text-align: justify !important;
|
| 45 |
+
text-align-last: left !important;
|
| 46 |
+
text-justify: auto !important;
|
| 47 |
+
text-indent: 2em;
|
| 48 |
+
margin-bottom: 0.6rem !important;
|
| 49 |
+
line-height: 1.75 !important;
|
| 50 |
+
word-break: normal;
|
| 51 |
+
line-break: loose;
|
| 52 |
+
overflow-wrap: anywhere;
|
| 53 |
font-kerning: normal;
|
|
|
|
| 54 |
font-feature-settings: "kern" 1, "liga" 1, "clig" 1;
|
|
|
|
| 55 |
orphans: 2;
|
| 56 |
widows: 2;
|
| 57 |
}
|
| 58 |
|
| 59 |
+
.reader-content p.numbered-line,
|
| 60 |
+
.content-body p.numbered-line {
|
| 61 |
+
padding-left: 2.8em;
|
| 62 |
+
text-indent: -2.8em;
|
| 63 |
+
text-align: left !important;
|
| 64 |
+
text-justify: none;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* Structured/tabular lines (e.g. Abhidhamma Patthana) β kept as standalone rows */
|
| 68 |
+
.reader-content p.structured-line,
|
| 69 |
+
.content-body p.structured-line {
|
| 70 |
+
text-align: left !important;
|
| 71 |
+
text-indent: 0;
|
| 72 |
+
text-justify: none;
|
| 73 |
+
margin-bottom: 0.1rem !important;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
/* Continuation / repetition lines starting with "..." β no paragraph indent */
|
| 79 |
+
.reader-content p.continuation-line,
|
| 80 |
+
.content-body p.continuation-line {
|
| 81 |
+
text-indent: 0 !important;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* All inline reference superscripts β same colour, same cursor for future click-to-expand */
|
| 85 |
+
sup.footnote-ref,
|
| 86 |
+
sup.abbrev-ref {
|
| 87 |
+
font-size: 0.7em;
|
| 88 |
+
color: #f59e0b;
|
| 89 |
+
opacity: 0.8;
|
| 90 |
+
vertical-align: super;
|
| 91 |
+
cursor: pointer;
|
| 92 |
+
letter-spacing: 0.03em;
|
| 93 |
+
transition: opacity 0.15s;
|
| 94 |
+
}
|
| 95 |
+
sup.footnote-ref:hover,
|
| 96 |
+
sup.abbrev-ref:hover {
|
| 97 |
+
opacity: 1;
|
| 98 |
+
text-decoration: underline;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* Standalone fallback block β remove gaps so it reads like part of the paragraph above */
|
| 102 |
+
.reader-content p.footnote-ref {
|
| 103 |
+
display: inline; /* collapses to inline flow β no block gap */
|
| 104 |
+
font-size: 0.72em;
|
| 105 |
+
opacity: 0.55;
|
| 106 |
+
margin: 0;
|
| 107 |
+
padding: 0;
|
| 108 |
+
text-indent: 0;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/* Wrap each footnote-ref in a block-level container so "display:inline" still works inside prose */
|
| 112 |
+
.reader-content p.footnote-ref::before {
|
| 113 |
+
content: " "; /* space before the marker */
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* Paragraph immediately before a footnote-ref: kill its bottom margin */
|
| 117 |
+
.reader-content p:has(+ p.footnote-ref) {
|
| 118 |
+
margin-bottom: 0 !important;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* Paragraph immediately after a footnote-ref: normal top gap resumes */
|
| 122 |
+
.reader-content p.footnote-ref + p {
|
| 123 |
+
margin-top: 0;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
/* Remove gap when <br/> is at the start of a <p> (edge case) */
|
| 127 |
.reader-content p:first-child {
|
| 128 |
margin-top: 0;
|
|
|
|
| 139 |
border-image: linear-gradient(to bottom, #D4AF37, #F4E7A5, #D4AF37) 1;
|
| 140 |
}
|
| 141 |
|
| 142 |
+
/* ββ Theme Backgrounds with Textures ββββββββββββββββββββββββββ */
|
| 143 |
+
.theme-light-bg {
|
| 144 |
+
background-color: #f8f6f0;
|
| 145 |
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.45' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.012'/%3E%3C/svg%3E");
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.theme-classic-bg {
|
| 149 |
+
background-color: #f5edd8;
|
| 150 |
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.45' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.015'/%3E%3C/svg%3E");
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.theme-dark-bg {
|
| 154 |
+
background-color: #1a1a2e;
|
| 155 |
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.45' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.018'/%3E%3C/svg%3E");
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
.glass-panel {
|
| 159 |
+
background: rgba(255, 255, 255, 0.03);
|
| 160 |
+
backdrop-filter: blur(12px);
|
| 161 |
+
-webkit-backdrop-filter: blur(12px);
|
| 162 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 163 |
+
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.glass-panel-light {
|
| 167 |
+
background: rgba(255, 255, 255, 0.6);
|
| 168 |
+
backdrop-filter: blur(10px);
|
| 169 |
+
-webkit-backdrop-filter: blur(10px);
|
| 170 |
+
border: 1px solid rgba(200, 134, 10, 0.1);
|
| 171 |
}
|
| 172 |
|
| 173 |
/* ββ Inline Content Headings ββ smaller than body text, highlight makes it stand out */
|