diff --git a/READER_IMPLEMENTATION_PLAN.md b/READER_IMPLEMENTATION_PLAN.md index 3466e3a..c3fac40 100644 --- a/READER_IMPLEMENTATION_PLAN.md +++ b/READER_IMPLEMENTATION_PLAN.md @@ -336,19 +336,21 @@ CREATE TABLE reader_settings ( CREATE INDEX idx_reader_settings_user ON reader_settings(user_id); -- PDF bookmarks (custom user bookmarks) -CREATE TABLE pdf_bookmarks ( +CREATE TABLE media_bookmarks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - page_number INTEGER NOT NULL, + page_number INTEGER, + chapter_number INTEGER, + cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position title VARCHAR(255) NOT NULL, - position VARCHAR(100), -- 'pdf:page:45' for consistency + position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency created_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(media_item_id, user_id, page_number) + UNIQUE(media_item_id, user_id, COALESCE(page_number, 0), COALESCE(chapter_number, 0)) ); -CREATE INDEX idx_pdf_bookmarks_media ON pdf_bookmarks(media_item_id); -CREATE INDEX idx_pdf_bookmarks_user ON pdf_bookmarks(user_id); +CREATE INDEX idx_media_bookmarks_media ON media_bookmarks(media_item_id); +CREATE INDEX idx_media_bookmarks_user ON media_bookmarks(user_id); ``` ### 2.2 Alter Existing Tables @@ -693,7 +695,7 @@ interface ReaderSettings { pdf_dual_page_threshold: number; pdf_minimap_enabled: boolean; pdf_outline_visible: boolean; - pdf_bookmarks_visible: boolean; + media_bookmarks_visible: boolean; hardware_acceleration: boolean; } @@ -5623,7 +5625,7 @@ function findOutlineNode( // Custom bookmarks for PDF pages (saved in database) // Procedural implementation (no OOP) -interface PDFBookmark { +interface MediaBookmark { id: string; mediaItemId: string; userId: string; @@ -5632,19 +5634,19 @@ interface PDFBookmark { createdAt: string; } -interface PDFBookmarksState { +interface MediaBookmarksState { mediaItemId: string; - bookmarks: PDFBookmark[]; + bookmarks: MediaBookmark[]; } -function createPDFBookmarks(mediaItemId: string): PDFBookmarksState { +function createMediaBookmarks(mediaItemId: string): MediaBookmarksState { return { mediaItemId, bookmarks: [] }; } -async function loadPDFBookmarks(state: PDFBookmarksState): Promise { +async function loadMediaBookmarks(state: MediaBookmarksState): Promise { try { const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`); if (!response.ok) throw new Error('Failed to load bookmarks'); @@ -5657,12 +5659,12 @@ async function loadPDFBookmarks(state: PDFBookmarksState): Promise { - const bookmark: PDFBookmark = { +): Promise { + const bookmark: MediaBookmark = { id: crypto.randomUUID(), mediaItemId: state.mediaItemId, userId: '', @@ -5697,10 +5699,10 @@ async function addPDFBookmark( } } -async function removePDFBookmark( - state: PDFBookmarksState, +async function removeMediaBookmark( + state: MediaBookmarksState, bookmarkId: string -): Promise { +): Promise { try { const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, { method: 'DELETE' @@ -5718,15 +5720,15 @@ async function removePDFBookmark( } } -function getPDFBookmarks(state: PDFBookmarksState): PDFBookmark[] { +function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] { return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber); } -function hasPDFBookmarkAt(state: PDFBookmarksState, pageNumber: number): boolean { +function hasMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): boolean { return state.bookmarks.some(b => b.pageNumber === pageNumber); } -function getPDFBookmarkAt(state: PDFBookmarksState, pageNumber: number): PDFBookmark | null { +function getMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): MediaBookmark | null { return state.bookmarks.find(b => b.pageNumber === pageNumber) || null; } ``` @@ -6717,6 +6719,824 @@ async function saveManualPanel(panel: Panel): Promise { } ``` +### 7.4 Page Order Presets + +**File:** `web/src/reader/comic/page-order.ts` + +```typescript +// Page order presets for manga/comics +// Auto-detect Japanese vs Western reading order +// Allow user override in case detection is wrong +// Procedural implementation (no OOP) + +type PageOrderMode = 'auto' | 'japanese' | 'western'; + +interface PageOrderConfig { + mode: PageOrderMode; + detectedOrder: PageOrderMode; + userOverride: boolean; +} + +interface PageOrderState { + config: PageOrderConfig; + totalPages: number; +} + +// Detect page order based on filename patterns +function detectPageOrder(pageNames: string[]): PageOrderMode { + if (pageNames.length < 2) return 'western'; + + const firstPage = pageNames[0].toLowerCase(); + const lastPage = pageNames[pageNames.length - 1].toLowerCase(); + + const hasFrontCover = /cover|front|001/.test(firstPage); + const hasBackCover = /back|end|最后的/.test(lastPage); + + if (hasFrontCover && !hasBackCover) { + return 'western'; + } + if (hasBackCover && !hasFrontCover) { + return 'japanese'; + } + + const chapterMatches = pageNames.filter(n => /ch-\d+|chapter/i.test(n)); + if (chapterMatches.length > 0) { + const firstChapter = chapterMatches[0]; + const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || '0'); + return pageNum > 0 ? 'western' : 'japanese'; + } + + return 'western'; +} + +function createPageOrderState(totalPages: number, pageNames: string[]): PageOrderState { + const detectedOrder = detectPageOrder(pageNames); + + return { + config: { + mode: 'auto', + detectedOrder, + userOverride: false + }, + totalPages + }; +} + +function setPageOrderMode(state: PageOrderState, mode: PageOrderMode): PageOrderState { + return { + ...state, + config: { + ...state.config, + mode, + userOverride: mode !== 'auto' + } + }; +} + +function getPageOrder(state: PageOrderState): PageOrderMode { + if (state.config.mode === 'auto') { + return state.config.detectedOrder; + } + return state.config.mode; +} + +function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] { + const order = getPageOrder(state); + + if (order === 'japanese') { + return [...pageNumbers].reverse(); + } + + return pageNumbers; +} + +function getDisplayPageNumber( + state: PageOrderState, + actualPage: number +): number { + const order = getPageOrder(state); + + if (order === 'japanese') { + return state.totalPages - actualPage + 1; + } + + return actualPage; +} +``` + +### 7.5 Extended Keyboard Shortcuts + +**File:** `web/src/reader/keyboard-shortcuts.ts` + +```typescript +// Extended keyboard shortcuts for all readers +// Procedural implementation (no OOP) + +interface KeyboardShortcutHandler { + onNextPage: () => void; + onPreviousPage: () => void; + onNextChapter: () => void; + onPreviousChapter: () => void; + onGoToPage: (page: number) => void; + onToggleBookmark: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onToggleFullscreen: () => void; + onClose: () => void; +} + +function setupKeyboardShortcuts( + container: HTMLElement, + handlers: KeyboardShortcutHandler, + maxPage: number +): void { + container.addEventListener('keydown', (e) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + switch (e.key) { + case 'ArrowRight': + case 'PageDown': + case 'l': + e.preventDefault(); + handlers.onNextPage(); + break; + + case 'ArrowLeft': + case 'PageUp': + case 'h': + e.preventDefault(); + handlers.onPreviousPage(); + break; + + case 'ArrowUp': + case 'k': + e.preventDefault(); + handlers.onPreviousPage(); + break; + + case 'ArrowDown': + case 'j': + e.preventDefault(); + handlers.onNextPage(); + break; + + case ' ': + e.preventDefault(); + handlers.onNextPage(); + break; + + case 'Home': + e.preventDefault(); + handlers.onGoToPage(1); + break; + + case 'End': + e.preventDefault(); + handlers.onGoToPage(maxPage); + break; + + case 'b': + if (!e.ctrlKey && !e.metaKey) { + e.preventDefault(); + handlers.onToggleBookmark(); + } + break; + + case '+': + case '=': + e.preventDefault(); + handlers.onZoomIn(); + break; + + case '-': + case '_': + e.preventDefault(); + handlers.onZoomOut(); + break; + + case '0': + e.preventDefault(); + handlers.onZoomIn(); + handlers.onZoomIn(); + handlers.onZoomIn(); + break; + + case 'f': + if (!e.ctrlKey && !e.metaKey) { + e.preventDefault(); + handlers.onToggleFullscreen(); + } + break; + + case 'Escape': + e.preventDefault(); + handlers.onClose(); + break; + + default: + if (e.key >= '1' && e.key <= '9') { + const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage); + e.preventDefault(); + handlers.onGoToPage(targetPage); + } + } + }); +} + +function showShortcutHelp(): void { + const help = document.createElement('div'); + help.className = 'keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50'; + help.innerHTML = ` +
+

Keyboard Shortcuts

+
+
/ Space Next page
+
Previous page
+
Home First page
+
End Last page
+
+ / - Zoom
+
B Toggle bookmark
+
F Fullscreen
+
1-9 Jump to %
+
+ +
+ `; + document.body.appendChild(help); + help.addEventListener('click', (e) => { + if (e.target === help) help.remove(); + }); +} +``` + +### 7.6 Page Slider/Scrubber + +**File:** `web/src/reader/comic/page-scrubber.ts` + +```typescript +// Page slider/scrubber for quick navigation +// Procedural implementation (no OOP) + +interface PageScrubberState { + currentPage: number; + totalPages: number; + container: HTMLElement; +} + +function createPageScrubber( + container: HTMLElement, + currentPage: number, + totalPages: number +): PageScrubberState { + const state: PageScrubberState = { + currentPage, + totalPages, + container + }; + + renderPageScrubber(state); + return state; +} + +function renderPageScrubber(state: PageScrubberState): void { + const existing = state.container.querySelector('.page-scrubber'); + existing?.remove(); + + const scrubber = document.createElement('div'); + scrubber.className = 'page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40'; + scrubber.innerHTML = ` + ${state.currentPage} + + ${state.totalPages} + `; + + const slider = scrubber.querySelector('.page-slider') as HTMLInputElement; + slider.addEventListener('input', (e) => { + const targetPage = parseInt((e.target as HTMLInputElement).value); + updatePageScrubber(state, targetPage); + }); + + slider.addEventListener('change', () => { + const targetPage = parseInt(slider.value); + navigateToPage(targetPage); + }); + + state.container.appendChild(scrubber); +} + +function updatePageScrubber(state: PageScrubberState, currentPage: number): PageScrubberState { + const newState = { ...state, currentPage }; + + const label = state.container.querySelector('.page-label'); + if (label) { + label.textContent = String(currentPage); + } + + return newState; +} + +function showPageScrubber(state: PageScrubberState): void { + const scrubber = state.container.querySelector('.page-scrubber'); + scrubber?.classList.remove('hidden'); +} + +function hidePageScrubber(state: PageScrubberState): void { + const scrubber = state.container.querySelector('.page-scrubber'); + scrubber?.classList.add('hidden'); +} + +function navigateToPage(page: number): void { + window.dispatchEvent(new CustomEvent('navigate-to-page', { detail: { page } })); +} +``` + +### 7.7 Gesture Controls + +**File:** `web/src/reader/gestures.ts` + +```typescript +// Touch gesture controls for mobile/tablet +// Procedural implementation (no OOP) + +interface GestureHandlers { + onSwipeLeft: () => void; + onSwipeRight: () => void; + onSwipeUp: () => void; + onSwipeDown: () => void; + onPinch: (scale: number) => void; + onTap: () => void; + onDoubleTap: () => void; +} + +interface GestureState { + touchStartX: number; + touchStartY: number; + touchStartTime: number; + lastTapTime: number; + initialPinchDistance: number; + scale: number; +} + +function setupGestureControls( + container: HTMLElement, + handlers: GestureHandlers +): void { + let state: GestureState = { + touchStartX: 0, + touchStartY: 0, + touchStartTime: 0, + lastTapTime: 0, + initialPinchDistance: 0, + scale: 1 + }; + + container.addEventListener('touchstart', (e) => { + if (e.touches.length === 1) { + state.touchStartX = e.touches[0].clientX; + state.touchStartY = e.touches[0].clientY; + state.touchStartTime = Date.now(); + } else if (e.touches.length === 2) { + state.initialPinchDistance = getPinchDistance(e.touches); + } + }, { passive: true }); + + container.addEventListener('touchend', (e) => { + const deltaX = e.changedTouches[0].clientX - state.touchStartX; + const deltaY = e.changedTouches[0].clientY - state.touchStartY; + const deltaTime = Date.now() - state.touchStartTime; + + if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) { + const now = Date.now(); + if (now - state.lastTapTime < 300) { + handlers.onDoubleTap(); + state.lastTapTime = 0; + } else { + state.lastTapTime = now; + setTimeout(() => { + if (state.lastTapTime !== 0) { + handlers.onTap(); + } + }, 300); + } + return; + } + + const minSwipeDistance = 50; + const maxSwipeTime = 500; + + if (deltaTime > maxSwipeTime) return; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + if (deltaX > minSwipeDistance) { + handlers.onSwipeRight(); + } else if (deltaX < -minSwipeDistance) { + handlers.onSwipeLeft(); + } + } else { + if (deltaY > minSwipeDistance) { + handlers.onSwipeDown(); + } else if (deltaY < -minSwipeDistance) { + handlers.onSwipeUp(); + } + } + }, { passive: true }); + + container.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + const currentDistance = getPinchDistance(e.touches); + if (state.initialPinchDistance > 0) { + const scale = currentDistance / state.initialPinchDistance; + state.scale = scale; + handlers.onPinch(scale); + } + } + }, { passive: true }); +} + +function getPinchDistance(touches: TouchList): number { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); +} +``` + +### 7.8 Panel Gap Controls + +**File:** `web/src/reader/comic/panel-gap.ts` + +```typescript +// Adjustable panel gap controls +// Procedural implementation (no OOP) + +interface PanelGapState { + gapSize: number; + showBorders: boolean; +} + +function createPanelGapState(initialGap: number = 4): PanelGapState { + return { + gapSize: initialGap, + showBorders: false + }; +} + +function setPanelGap(state: PanelGapState, gap: number): PanelGapState { + const clampedGap = Math.max(0, Math.min(20, gap)); + + document.documentElement.style.setProperty('--panel-gap', `${clampedGap}px`); + + return { ...state, gapSize: clampedGap }; +} + +function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { + return setPanelGap(state, state.gapSize + amount); +} + +function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { + return setPanelGap(state, state.gapSize - amount); +} + +function togglePanelBorders(state: PanelGapState): PanelGapState { + const newShowBorders = !state.showBorders; + + document.documentElement.style.setProperty( + '--panel-border-width', + newShowBorders ? '1px' : '0px' + ); + + return { ...state, showBorders: newShowBorders }; +} + +function renderPanelGapControls(container: HTMLElement, state: PanelGapState): void { + const existing = container.querySelector('.panel-gap-controls'); + existing?.remove(); + + const controls = document.createElement('div'); + controls.className = 'panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40'; + controls.innerHTML = ` + + ${state.gapSize}px + + + `; + + controls.querySelector('.panel-gap-increase')?.addEventListener('click', () => { + const newState = increasePanelGap(state); + updatePanelGapUI(controls, newState); + }); + + controls.querySelector('.panel-gap-decrease')?.addEventListener('click', () => { + const newState = decreasePanelGap(state); + updatePanelGapUI(controls, newState); + }); + + controls.querySelector('.panel-gap-borders')?.addEventListener('click', () => { + const newState = togglePanelBorders(state); + updatePanelGapUI(controls, newState); + }); + + container.appendChild(controls); +} + +function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void { + const gapLabel = container.querySelector('span'); + if (gapLabel) { + gapLabel.textContent = `${state.gapSize}px`; + } + + const bordersBtn = container.querySelector('.panel-gap-borders'); + if (bordersBtn) { + bordersBtn.textContent = state.showBorders ? '▦' : '▢'; + } +} + +const panelGapCSS = ` + :root { + --panel-gap: 4px; + --panel-border-width: 0px; + } + + .panel-zoom-container { + gap: var(--panel-gap); + } + + .panel-zoom-container.with-borders { + background: rgba(255, 255, 255, 0.1); + padding: var(--panel-gap); + } + + .panel-borders { + border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3); + } +`; +``` + +### 7.9 Background Color Options + +**File:** `web/src/reader/comic/background-color.ts` + +```typescript +// Background color options for manga/comics +// Procedural implementation (no OOP) + +type BackgroundColor = 'black' | 'white' | 'gray' | 'sepia' | 'custom'; + +interface BackgroundColorState { + current: BackgroundColor; + customColor: string; +} + +const backgroundColors: Record = { + black: '#000000', + white: '#ffffff', + gray: '#333333', + sepia: '#f4ecd8', + custom: '' +}; + +function createBackgroundColorState( + initial: BackgroundColor = 'black' +): BackgroundColorState { + return { + current: initial, + customColor: '#000000' + }; +} + +function setBackgroundColor( + state: BackgroundColorState, + color: BackgroundColor, + customColor?: string +): BackgroundColorState { + const newState: BackgroundColorState = { + current: color, + customColor: customColor || state.customColor + }; + + const bgColor = color === 'custom' + ? newState.customColor + : backgroundColors[color]; + + document.documentElement.style.setProperty('--reader-bg-color', bgColor); + + const viewer = document.querySelector('.reader-content'); + if (viewer) { + viewer.style.backgroundColor = bgColor; + } + + localStorage.setItem('reader-background-color', color); + + return newState; +} + +function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState { + const order: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; + const currentIndex = order.indexOf(state.current); + const nextIndex = (currentIndex + 1) % order.length; + + return setBackgroundColor(state, order[nextIndex]); +} + +function renderBackgroundColorPicker( + container: HTMLElement, + state: BackgroundColorState +): void { + const existing = container.querySelector('.background-color-picker'); + existing?.remove(); + + const picker = document.createElement('div'); + picker.className = 'background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40'; + + const colors: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; + + colors.forEach(color => { + const btn = document.createElement('button'); + btn.className = `w-8 h-8 rounded-full border-2 ${ + state.current === color ? 'border-blue-500' : 'border-transparent' + }`; + btn.style.backgroundColor = backgroundColors[color]; + btn.title = color.charAt(0).toUpperCase() + color.slice(1); + btn.addEventListener('click', () => { + const newState = setBackgroundColor(state, color); + updateBackgroundColorUI(picker, newState); + }); + picker.appendChild(btn); + }); + + container.appendChild(picker); +} + +function updateBackgroundColorUI(container: HTMLElement, state: BackgroundColorState): void { + const buttons = container.querySelectorAll('button'); + const colors: BackgroundColor[] = ['black', 'white', 'gray', 'sepia']; + + buttons.forEach((btn, index) => { + btn.classList.toggle('border-blue-500', colors[index] === state.current); + }); +} +``` + +### 7.10 Chapter Markers + +**File:** `web/src/reader/comic/chapter-markers.ts` + +```typescript +// Chapter markers for manga/comics +// Visual indicators for chapter boundaries +// Procedural implementation (no OOP) + +interface ChapterInfo { + chapterNumber: number; + pageStart: number; + pageEnd: number; + title?: string; +} + +interface ChapterMarkerState { + chapters: ChapterInfo[]; + currentChapter: number; + showMarkers: boolean; +} + +function createChapterMarkerState( + chapters: ChapterInfo[], + currentPage: number +): ChapterMarkerState { + const currentChapter = chapters.find( + c => currentPage >= c.pageStart && currentPage <= c.pageEnd + )?.chapterNumber || 1; + + return { + chapters, + currentChapter, + showMarkers: true + }; +} + +function renderChapterMarkers( + container: HTMLElement, + state: ChapterMarkerState +): void { + if (!state.showMarkers) return; + + const markersContainer = document.createElement('div'); + markersContainer.className = 'chapter-markers absolute left-0 right-0 pointer-events-none z-10'; + + state.chapters.forEach(chapter => { + const marker = document.createElement('div'); + marker.className = 'chapter-marker flex items-center gap-2 text-sm text-gray-400'; + + const isCurrentChapter = chapter.chapterNumber === state.currentChapter; + + marker.style.position = 'absolute'; + marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`; + marker.style.left = '10px'; + + marker.innerHTML = ` + + ${chapter.title || `Chapter ${chapter.chapterNumber}`} + + p.${chapter.pageStart} + ${isCurrentChapter ? '' : ''} + `; + + markersContainer.appendChild(marker); + }); + + const existing = container.querySelector('.chapter-markers'); + existing?.remove(); + container.appendChild(markersContainer); +} + +function updateCurrentChapter( + state: ChapterMarkerState, + currentPage: number +): ChapterMarkerState { + const currentChapter = state.chapters.find( + c => currentPage >= c.pageStart && currentPage <= c.pageEnd + )?.chapterNumber || state.currentChapter; + + if (currentChapter !== state.currentChapter) { + const newState = { ...state, currentChapter }; + + const markers = document.querySelector('.chapter-markers'); + if (markers) { + renderChapterMarkers(markers.parentElement!, newState); + } + + return newState; + } + + return state; +} + +function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState { + const newState = { ...state, showMarkers: !state.showMarkers }; + + const markers = document.querySelector('.chapter-markers'); + if (markers) { + markers.classList.toggle('hidden', !newState.showMarkers); + } + + return newState; +} + +function scrollToChapter( + state: ChapterMarkerState, + chapterNumber: number +): void { + const chapter = state.chapters.find(c => c.chapterNumber === chapterNumber); + if (chapter) { + window.dispatchEvent(new CustomEvent('navigate-to-page', { + detail: { page: chapter.pageStart } + })); + } +} + +const chapterMarkerCSS = ` + .chapter-marker { + padding: 4px 8px; + margin-left: -18px; + opacity: 0.7; + transition: opacity 0.2s; + } + + .chapter-marker:hover { + opacity: 1; + } + + .chapter-marker .current-indicator { + color: #3b82f6; + animation: pulse 2s infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + .chapter-marker-line { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(to right, rgba(255,255,255,0.1), transparent); + } +`; +``` + --- ## 8. Lazy Loading & Caching @@ -7689,7 +8509,7 @@ This implementation plan provides a comprehensive roadmap for building a modern, **Next steps:** 1. Review and approve this plan 2. Begin Phase 1: Infrastructure & Basic Reader -3. Create database schema (add pdf_bookmarks table) +3. Create database schema (add media_bookmarks table with chapter_number and cfi_position fields) 4. Implement parser manager and CIF types 5. Build universal reader shell (procedural style) 6. Implement parsers (start with EPUB, TXT - simplest first)