File size: 14,956 Bytes
634c146
8052574
 
 
f44e5b9
d2c65ec
165565d
9d341c1
8052574
 
 
 
 
 
 
 
d2c65ec
 
 
8052574
 
05b80f3
 
 
 
 
 
 
 
 
 
 
 
 
bf53ae4
2f3b92c
d2c65ec
ef0ec43
 
d2c65ec
ef0ec43
 
 
 
 
bf53ae4
ef0ec43
 
bf53ae4
d2c65ec
 
 
 
 
 
 
c9166a1
 
608254b
ef0ec43
 
 
 
12029d4
 
 
 
 
ef0ec43
 
 
 
 
12029d4
ef0ec43
12029d4
 
 
 
 
 
ef0ec43
12029d4
 
ef0ec43
 
 
d2c65ec
ef0ec43
d2c65ec
 
 
 
ef0ec43
d2c65ec
 
 
 
 
 
 
ef0ec43
 
12029d4
ef0ec43
d2c65ec
ef0ec43
 
e797747
ef0ec43
d2c65ec
ef0ec43
608254b
ef0ec43
 
 
575f508
2f3b92c
 
 
8052574
634c146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8052574
 
 
 
05b80f3
8052574
 
 
 
 
d2c65ec
8052574
634c146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d341c1
634c146
 
 
4644d66
634c146
8052574
 
 
 
 
 
ef0ec43
8052574
 
 
 
 
 
 
05b80f3
 
 
 
 
 
 
 
 
 
 
 
0328af1
 
 
 
 
 
 
 
 
1e5e585
8052574
 
d2c65ec
 
 
 
 
 
 
 
 
 
 
 
23b4d13
 
 
d2c65ec
 
 
 
 
165565d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8052574
9d341c1
f44e5b9
165565d
 
 
 
 
 
 
8052574
603ead8
 
 
 
 
 
f44e5b9
603ead8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
05b80f3
603ead8
 
 
 
 
 
 
 
ef0ec43
603ead8
 
 
 
 
 
 
 
 
 
 
 
05b80f3
 
 
 
 
603ead8
 
8052574
603ead8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23b4d13
603ead8
f44e5b9
603ead8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8052574
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
import React, { useEffect } from 'react';
import { useReaderStore, useThemeStore } from '../../stores/appStore';
import api from '../../lib/api';
import { motion, AnimatePresence } from 'framer-motion';
import SelectionPopup from '../reader/SelectionPopup';
import ReferencePopup, { type RefPopupPos } from '../reader/ReferencePopup';
import ErrorBoundary from '../common/ErrorBoundary';
import { useSwipeNav } from '../../hooks/useSwipeNav';

const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
  dark:    { reader: 'bg-[#1e1e36] text-[#e8e4da]', muted: 'text-[#999]' },
  light:   { reader: 'bg-[#fdfaf5] text-[#1a1a1a]', muted: 'text-[#666]' },
  classic: { reader: 'bg-[#faf7f2] text-[#1a1a1a]', muted: 'text-[#555]' },
};

const SHELL_STYLES: Record<string, string> = {
  dark:    'theme-dark-bg',
  light:   'theme-light-bg',
  classic: 'theme-classic-bg',
};

/** Inject <mark> tags around every keyword occurrence in an HTML string.
 *  Replaces only text nodes (content between > and <) to avoid breaking tags. */
function highlightHtml(html: string, query: string): string {
  if (!query.trim()) return html;
  const tokens = query.trim().split(/\s+/).filter(Boolean);
  if (tokens.length === 0) return html;
  const escaped = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
  const pattern = new RegExp(`(${escaped.join('|')})`, 'g');
  return html.replace(/>([^<]+)</g, (_match, text) => {
    return `>${text.replace(pattern, '<mark class="search-highlight">$1</mark>')}<`;
  });
}

const renderContent = (html: string, fontSize: number, endMarkers: string[]) => {
  return (
    <div style={{ fontSize: `${fontSize}px` }}>
      {html ? (
        <div
          className="content-body reader-content"
          dangerouslySetInnerHTML={{ __html: html }}
        />
      ) : (
        <p className="text-[#888] italic text-center py-10">ไม่มีข้อความในหน้านี้</p>
      )}
      {/* End markers from raw <B> tags containing "จบ" */}
      {endMarkers.length > 0 && (
        <div className="space-y-1 pt-2">
          {endMarkers.map((title, i) => (
            <div key={i} className="flex items-center gap-4 py-2 opacity-40">
              <div className="flex-1 h-px bg-gradient-to-r from-transparent via-[#888] to-transparent" />
              <p className="text-[10px] text-[#888] tracking-[0.2em] uppercase font-medium">
                {title}
              </p>
              <div className="flex-1 h-px bg-gradient-to-r from-transparent via-[#888] to-transparent" />
            </div>
          ))}
        </div>
      )}
    </div>
  );
};

/** First page — split at the "ขอนอบน้อม" homage h4:
 *  TOP (everything up to and including homage): centered, clean (no bg/border on h4)
 *  BOTTOM (after homage): normal (text-left, h4 with bg/border)
 *  This works for all volumes regardless of where L0/L1 appear relative to homage. */
const renderFirstPage = (html: string, fontSize: number, volumeTitle: string) => {
  // Split pitaka name from rest of title
  const idx = volumeTitle.indexOf('ปิฎก');
  const line1 = idx !== -1 ? volumeTitle.substring(0, idx + 4) : volumeTitle;
  const line2 = idx !== -1 ? volumeTitle.substring(idx + 4).trim() : '';

  // Find split point: end of the </h4> containing "ขอนอบน้อม" (homage)
  let splitIdx = -1;
  if (html) {
    const homagePos = html.indexOf('ขอนอบน้อม');
    if (homagePos !== -1) {
      const endH4 = html.indexOf('</h4>', homagePos);
      splitIdx = endH4 !== -1 ? endH4 + 5 : html.length;
    }
  }
  const topPart = splitIdx > 0 ? html.substring(0, splitIdx) : '';
  const bottomPart = splitIdx >= 0 ? html.substring(splitIdx) : html || '';

  return (
    <div
      style={{ fontSize: `${fontSize}px` }}
    >
      {/* Volume title — centered explicitly */}
      <div className="text-center">
        <p className="text-lg md:text-xl font-bold text-[#c8860a] leading-tight">
          {line1}
        </p>
        {line2 && (
          <p className="text-base md:text-lg text-[#c8860a] leading-tight">
            {line2}
          </p>
        )}
        <div className="border-b border-[#c8860a]/15 my-3" />
      </div>
      {html ? (
        <>
          {/* Top: everything before/at homage — centered, clean */}
          <div
            className="first-page-top text-center"
            dangerouslySetInnerHTML={{ __html: topPart }}
          />
          {/* Bottom: after homage — normal h4 styling, text-justify */}
          <div
            className="content-body reader-content"
            dangerouslySetInnerHTML={{ __html: bottomPart }}
          />
        </>
      ) : (
        <p className="text-[#888] italic text-center py-10">ไม่มีข้อความในหน้านี้</p>
      )}
    </div>
  );
};

const articleVariants = {
  enter: (direction: 'forward' | 'backward' | 'none') => ({
    y: direction === 'forward' ? 30 : direction === 'backward' ? -30 : 0,
    opacity: 0,
  }),
  center: {
    y: 0,
    opacity: 1,
  },
  exit: (direction: 'forward' | 'backward' | 'none') => ({
    y: direction === 'forward' ? -30 : direction === 'backward' ? 30 : 0,
    opacity: 0,
  }),
};

const ReaderPanel: React.FC = () => {
  const {
    currentVolume, currentPage,
    setCurrentContent, setTotalPages,
    setVolumeTitle, highlightQuery,
  } = useReaderStore();

  const { theme, fontSize } = useThemeStore();
  const [content, setContent] = React.useState<any>(null);
  const [loading, setLoading] = React.useState(true);
  const [refPos, setRefPos] = React.useState<RefPopupPos | null>(null);

  // Derived state to track direction across page transitions (preserving direction when loading finishes)
  const [prevPage, setPrevPage] = React.useState(currentPage);
  const [prevVolume, setPrevVolume] = React.useState(currentVolume);
  const [direction, setDirection] = React.useState<'forward' | 'backward' | 'none'>('none');

  if (currentPage !== prevPage || currentVolume !== prevVolume) {
    const isVolChange = currentVolume !== prevVolume;
    const dir = isVolChange
      ? 'none'
      : currentPage > prevPage
        ? 'forward'
        : currentPage < prevPage
          ? 'backward'
          : 'none';
    setDirection(dir);
    setPrevPage(currentPage);
    setPrevVolume(currentVolume);
  }

  const swipeRef = useSwipeNav<HTMLDivElement>({
    disabled: loading
  });

  useEffect(() => {
    setLoading(true);
    api.get(`/api/pages/${currentVolume}/${currentPage}`)
      .then(res => {
        const data = res.data;
        setContent(data);
        setCurrentContent(data.content_text || '');
        // Auto-load TTS when page changes
        if (data.total_pages) setTotalPages(data.total_pages);
        if (data.title) setVolumeTitle(data.title);
      })
      .catch(err => console.error('Page load error:', err))
      .finally(() => setLoading(false));
  }, [currentVolume, currentPage]);

  // Scroll to first highlighted match after render
  useEffect(() => {
    if (!highlightQuery || loading) return;
    const timer = setTimeout(() => {
      const first = document.querySelector('mark.search-highlight') as HTMLElement | null;
      if (first) {
        first.scrollIntoView({ behavior: 'smooth', block: 'center' });
      }
    }, 150); // wait for framer-motion animation
    return () => clearTimeout(timer);
  }, [highlightQuery, loading, content]);

  useEffect(() => {
    if (!loading && content) {
      const container = document.getElementById('reader-scroll-container');
      if (container) {
        container.scrollTop = 0;
      }
    }
  }, [loading, content]);

  const { reader: readerCls, muted: mutedCls } = THEME_STYLES[theme] ?? THEME_STYLES.dark;
  const shellCls = SHELL_STYLES[theme] ?? SHELL_STYLES.dark;

  const handleArticleClick = (e: React.MouseEvent) => {
    const target = e.target as HTMLElement;
    if (target.tagName.toLowerCase() === 'sup') {
      const isAbbrev = target.classList.contains('abbrev-ref');
      const isFootnote = target.classList.contains('footnote-ref');
      
      if (isAbbrev || isFootnote) {
        const rect = target.getBoundingClientRect();
        setRefPos({
          x: rect.left + rect.width / 2,
          y: rect.bottom,
          id: target.innerText,
          type: isAbbrev ? 'abbrev' : 'footnote',
          vol: currentVolume,
          page: currentPage
        });
      }
    }
  };

  const scrollToElement = (id: string) => {
    const element = document.getElementById(id);
    if (element) {
      element.scrollIntoView({ behavior: 'smooth', block: 'center' });
      element.classList.add('bg-[#c8860a]/20');
      setTimeout(() => element.classList.remove('bg-[#c8860a]/20'), 2000);
    }
  };

  const scrollToFootnote = (fnId: string) => {
    const cleanId = fnId.replace(/[()\[\]-]/g, '').trim();
    scrollToElement(`fn-item-${cleanId}`);
    setRefPos(null); // Close popup after jump
  };

  return (
    <div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
      <SelectionPopup />
      <ErrorBoundary componentName="ReferencePopup">
        <ReferencePopup 
          pos={refPos} 
          onClose={() => setRefPos(null)} 
          onJump={scrollToFootnote}
        />
      </ErrorBoundary>
      <div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
        {loading && (
          <div className="flex flex-col items-center justify-center py-48 gap-4">
            <div className="w-10 h-10 border-4 border-[#c8860a]/20 border-t-[#c8860a] rounded-full animate-spin" />
            <p className="text-[#c8860a] text-sm animate-pulse">กำลังอัญเชิญข้อความ…</p>
          </div>
        )}

        {!loading && (
          <AnimatePresence custom={direction} mode="popLayout">
            {content ? (
              <motion.article
                key={`${currentVolume}-${currentPage}`}
                custom={direction}
                variants={articleVariants}
                initial="enter"
                animate="center"
                exit="exit"
                transition={{ duration: 0.35, ease: 'easeOut' }}
                className={`rounded-xl p-8 lg:p-12 ${readerCls}`}
                onClick={handleArticleClick}
              >
                {currentPage === 1 || content?.page_number === 1 ? (
                  renderFirstPage(
                    highlightHtml(content.content_html_formatted || content.content_html || '', highlightQuery),
                    fontSize,
                    content.title || ''
                  )
                ) : (
                  <>
                    <header className="mb-4 pb-0">
                      <p className="text-xs text-[#888] tracking-widest uppercase leading-relaxed">
                        {content.title}
                      </p>
                      {content.sections?.filter((s: {level: number}) => s.level === 0).map((sec: {title: string}, i: number) => (
                        <p key={`l0-${i}`} className="text-xs text-[#888] tracking-widest uppercase leading-relaxed">
                          {sec.title}
                        </p>
                      ))}
                      {content.sections?.filter((s: {level: number}) => s.level === 1).map((sec: {title: string}, i: number) => (
                        <p key={`l1-${i}`} className="text-sm text-[#888] tracking-widest uppercase leading-relaxed">
                          {sec.title}
                        </p>
                      ))}
                    </header>
                    <div className="border-b border-[#c8860a]/15 mb-5" />
                    {renderContent(
                      highlightHtml(content.content_html_formatted || content.content_html || '', highlightQuery),
                      fontSize,
                      content.end_markers || []
                    )}
                  </>
                )}

                {/* Footnotes Section */}
                {content.footnotes && content.footnotes.length > 0 && (
                  <div className="mt-16 pt-8 border-t border-[#c8860a]/20">
                    <h5 className="text-[#c8860a] text-xs font-bold tracking-widest uppercase mb-6 flex items-center gap-2">
                      <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 4v12l-4-2-4 2V4M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
                      </svg>
                      เชิงอรรถ
                    </h5>
                    <div className="space-y-4">
                      {content.footnotes.map((fn: any, idx: number) => {
                        const cleanId = fn.id.replace(/[()\[\]-]/g, '').trim();
                        return (
                          <div 
                            key={idx} 
                            id={`fn-item-${cleanId}`}
                            className={`flex gap-3 text-[13px] leading-relaxed ${mutedCls} hover:opacity-100 transition-all p-2 -m-2 rounded-lg cursor-pointer group`}
                            onClick={() => scrollToElement(`ref-${cleanId}`)}
                            title="คลิกเพื่อกลับไปยังเนื้อหา"
                          >
                            <span className="text-[#c8860a] font-bold min-w-[24px] text-right shrink-0 group-hover:scale-110 transition-transform">{fn.id}</span>
                            <span className="font-light">{fn.content}</span>
                          </div>
                        );
                      })}
                    </div>
                  </div>
                )}

                <footer className="mt-12 pt-6 border-t border-[#888]/10 text-center">
                  <span className="text-xs text-[#888]">
                    — เล่ม {currentVolume} หน้า {currentPage} —
                  </span>
                </footer>
              </motion.article>
            ) : (
              <motion.div
                key="empty"
                initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
                className="text-center py-40 text-[#888]"
              >
                <div className="text-5xl mb-6 opacity-40">📖</div>
                <p className="text-lg">ยังไม่ได้เลือกข้อความ</p>
                <p className="text-sm mt-2 opacity-60">
                  เลือกเล่มจากสารบัญ หรือค้นหาคำในพระไตรปิฎก
                </p>
              </motion.div>
            )}
          </AnimatePresence>
        )}
      </div>
    </div>
  );
};

export default ReaderPanel;