diff --git a/web/src/reader/panel-dock-system.ts b/web/src/reader/panel-dock-system.ts new file mode 100644 index 0000000..2b087d7 --- /dev/null +++ b/web/src/reader/panel-dock-system.ts @@ -0,0 +1,247 @@ +// Modular dockable panel system - handles drag, lock, snap-back, window-shade +// Procedural style: Functions, not classes + +import { + saveSettings, + loadSettings, + getDefaultSettings, +} from "./settings-manager"; + +interface PanelDockState { + panels: Map; + dragState: DragState | null; + dockZones: DockZone[]; +} + +interface DragState { + panelId: string; + startX: number; + startY: number; + currentX: number; + currentY: number; + isLocked: boolean; +} + +interface DockZone { + side: "left" | "right"; + x: number; + width: number; + height: number; +} + +const state: PanelDockState = { + panels: new Map(), + dragState: null, + dockZones: [ + { side: "left", x: 0, width: 400, height: window.innerHeight }, + { + side: "right", + x: window.innerWidth - 400, + width: 400, + height: window.innerHeight, + }, + ], +}; + +// Initialize all panels from settings +function initializePanelDockSystem(): void { + const settings = loadSettings(); + + for (const [panelId, panelState] of Object.entries(settings.panel_layout)) { + registerPanel(panelId, panelState); + } + + setupDragHandlers(); + setupWindowShadeHandlers(); + setupLockHandlers(); +} + +// Register a panel with the dock system +function registerPanel(panelId: string, panelState: PanelState): void { + state.panels.set(panelId, panelState); + applyPanelState(panelId, panelState); +} + +// Apply panel state to DOM +function applyPanelState(panelId: string, panelState: PanelState): void { + const panel = document.querySelector(`[data-panel="${panelId}"]`); + if (!panel) return; + + const container = panel.parentElement; + if (!container) return; + + // Apply side positioning + if (panelState.side === "left") { + container.style.left = "0"; + container.style.right = "auto"; + } else if (panelState.side === "right") { + container.style.right = "0"; + container.style.left = "auto"; + } else { + container.style.left = "-9999px"; + } + + // Apply width + panel.style.width = `${panelState.width_px}px`; + + // Apply collapsed (window-shade) state + if (panelState.collapsed) { + panel.classList.add("panel-collapsed"); + panel.querySelector(".panel-content")?.classList.add("hidden"); + } else { + panel.classList.remove("panel-collapsed"); + panel.querySelector(".panel-content")?.classList.remove("hidden"); + } + + // Apply lock state + const lockBtn = panel.querySelector(".panel-lock"); + if (lockBtn) { + lockBtn.textContent = panelState.locked ? "🔒" : "🔓"; + } +} + +// Setup mouse/touch drag handlers +function setupDragHandlers(): void { + document + .querySelectorAll(".dockable-panel .panel-header") + .forEach((header) => { + header.addEventListener("mousedown", handleDragStart); + header.addEventListener("touchstart", handleDragStart, { + passive: false, + }); + }); + + document.addEventListener("mousemove", handleDragMove); + document.addEventListener("touchmove", handleDragMove, { passive: false }); + document.addEventListener("mouseup", handleDragEnd); + document.addEventListener("touchend", handleDragEnd); +} + +function handleDragStart(e: MouseEvent | TouchEvent): void { + const header = e.target.closest(".panel-header") as HTMLElement; + const panel = header?.closest(".dockable-panel") as HTMLElement; + if (!panel) return; + + const panelId = panel.dataset.panel; + const panelState = state.panels.get(panelId); + + // Check if locked + if (panelState?.locked) return; + + const clientX = "touches" in e ? e.touches[0].clientX : e.clientX; + const clientY = "touches" in e ? e.touches[0].clientY : e.clientY; + + state.dragState = { + panelId: panelId!, + startX: clientX, + startY: clientY, + currentX: clientX, + currentY: clientY, + isLocked: panelState?.locked || false, + }; + + panel.classList.add("dragging"); +} + +function handleDragMove(e: MouseEvent | TouchEvent): void { + if (!state.dragState) return; + + const clientX = "touches" in e ? e.touches[0].clientX : e.clientX; + const clientY = "touches" in e ? e.touches[0].clientY : e.clientY; + + state.dragState.currentX = clientX; + state.dragState.currentY = clientY; + + const panel = document.querySelector( + `[data-panel="${state.dragState.panelId}"]`, + ); + const container = panel?.parentElement; + if (container) { + container.style.transform = `translateX(${clientX - state.dragState.startX}px)`; + } +} + +function handleDragEnd(e: MouseEvent | TouchEvent): void { + if (!state.dragState) return; + + const { panelId, currentX } = state.dragState; + const panel = document.querySelector(`[data-panel="${panelId}"]`); + const container = panel?.parentElement; + + // Reset transform + container.style.transform = ""; + panel?.classList.remove("dragging"); + + // Determine drop zone + const newSide = currentX < window.innerWidth / 2 ? "left" : "right"; + const panelState = state.panels.get(panelId); + + if (panelState) { + // Check if dropped in valid zone + const isValidDrop = newSide === "left" || newSide === "right"; + + if (isValidDrop) { + panelState.last_valid_side = panelState.side; // Save previous valid position + panelState.side = newSide; + } else { + // Snap back to last valid position + panelState.side = panelState.last_valid_side; + } + + applyPanelState(panelId, panelState); + savePanelState(panelId, panelState); + } + + state.dragState = null; +} + +// Setup window-shade (collapse/expand) handlers +function setupWindowShadeHandlers(): void { + document.querySelectorAll(".window-shade-toggle").forEach((btn) => { + btn.addEventListener("click", (e) => { + const panel = (e.target as HTMLElement).closest( + ".dockable-panel", + ) as HTMLElement; + const panelId = panel.dataset.panel; + const panelState = state.panels.get(panelId); + + if (panelState) { + panelState.collapsed = !panelState.collapsed; + applyPanelState(panelId, panelState); + savePanelState(panelId, panelState); + } + }); + }); +} + +// Setup lock toggle handlers +function setupLockHandlers(): void { + document.querySelectorAll(".panel-lock").forEach((btn) => { + btn.addEventListener("click", (e) => { + const panel = (e.target as HTMLElement).closest( + ".dockable-panel", + ) as HTMLElement; + const panelId = panel.dataset.panel; + const panelState = state.panels.get(panelId); + + if (panelState) { + panelState.locked = !panelState.locked; + applyPanelState(panelId, panelState); + savePanelState(panelId, panelState); + } + }); + }); +} + +// Persist panel state to settings +async function savePanelState( + panelId: string, + panelState: PanelState, +): Promise { + const settings = loadSettings(); + settings.panel_layout[panelId as keyof typeof settings.panel_layout] = + panelState; + await saveSettings(settings); +} + +export { initializePanelDockSystem, registerPanel, applyPanelState }; diff --git a/web/src/reader/parser-manager.ts b/web/src/reader/parser-manager.ts new file mode 100644 index 0000000..c424eaa --- /dev/null +++ b/web/src/reader/parser-manager.ts @@ -0,0 +1,160 @@ +// Parser Manager - Routes files to appropriate parsers +// Procedural style: Functions, not classes + +import JSZip from "jszip"; + +// ============================================================ +// Parser Registry +// ============================================================ + +const PARSER_REGISTRY: ParserEntry[] = [ + { + format: "epub", + mimeType: "application/epub+zip", + extensions: [".epub"], + side: "client", + }, + { + format: "fb2", + mimeType: "application/fb2", + extensions: [".fb2", ".fb2.zip"], + side: "client", + }, + { + format: "txt", + mimeType: "text/plain", + extensions: [".txt"], + side: "client", + }, + { + format: "html", + mimeType: "text/html", + extensions: [".html", ".htm"], + side: "client", + }, + { + format: "mobi", + mimeType: "application/x-mobipocket-ebook", + extensions: [".mobi", ".azw"], + side: "server", + }, + { + format: "azw3", + mimeType: "application/vnd.amazon.mobi8-ebook", + extensions: [".azw3"], + side: "server", + }, + { + format: "docx", + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + extensions: [".docx"], + side: "server", + }, + { + format: "rtf", + mimeType: "application/rtf", + extensions: [".rtf"], + side: "server", + }, +]; + +interface ParserEntry { + format: string; + mimeType: string; + extensions: string[]; + side: "client" | "server"; +} + +// ============================================================ +// Parser Detection +// ============================================================ + +export function detectParserFormat( + mimeType: string, + extension: string, +): ParserEntry | null { + return ( + PARSER_REGISTRY.find( + (entry) => + entry.mimeType === mimeType || + entry.extensions.includes(extension.toLowerCase()), + ) || null + ); +} + +export function requiresServerParsing( + mimeType: string, + extension: string, +): boolean { + const entry = detectParserFormat(mimeType, extension); + return entry?.side === "server" || false; +} + +// ============================================================ +// Main Parse Function (Router) +// ============================================================ + +export async function parseEbook( + file: Blob, + mimeType: string, + extension: string, +): Promise { + const entry = detectParserFormat(mimeType, extension); + + if (!entry) { + throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`); + } + + if (entry.side === "server") { + return parseEbookOnServer(file, entry.format); + } else { + return parseEbookOnClient(file, entry.format); + } +} + +// ============================================================ +// Client-Side Parsing +// ============================================================ + +async function parseEbookOnClient( + file: Blob, + format: string, +): Promise { + switch (format) { + case "epub": + return parseEPUB(file); + case "fb2": + return parseFB2(file); + case "txt": + return parseTXT(file); + case "html": + return parseHTML(file); + default: + throw new Error(`Client-side parser not implemented for: ${format}`); + } +} + +// ============================================================ +// Server-Side Parsing (API Call) +// ============================================================ + +async function parseEbookOnServer( + file: Blob, + format: string, +): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("format", format); + + const response = await fetch("/readers/parse", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`Server parsing failed: ${response.statusText}`); + } + + return await response.json(); +} diff --git a/web/src/reader/progress-indicator.ts b/web/src/reader/progress-indicator.ts new file mode 100644 index 0000000..6d6dd03 --- /dev/null +++ b/web/src/reader/progress-indicator.ts @@ -0,0 +1,66 @@ +// KOReader-style switchable progress indicator + +import { Alpine } from "../alpine"; +import { getReadingSpeed } from "./api"; + +interface ProgressDisplay { + mode: "pages" | "chapter" | "percentage" | "time-left"; + text: string; +} + +function calculateProgress( + currentPage: number, + totalPages: number, + currentChapterPage: number, + chapterPages: number, + readingSpeed?: ReadingSpeed, +): ProgressDisplay { + const mode = getCurrentProgressMode(); // From settings + + switch (mode) { + case "pages": + return { + mode: "pages", + text: `${currentPage}/${totalPages}`, + }; + + case "chapter": + return { + mode: "chapter", + text: `${currentChapterPage}/${chapterPages}`, + }; + + case "percentage": + const percentage = Math.round((currentPage / totalPages) * 100); + return { + mode: "percentage", + text: `${percentage}%`, + }; + + case "time-left": + if (!readingSpeed) { + return { mode: "time-left", text: "--:--" }; + } + const pagesLeft = totalPages - currentPage; + const minutesLeft = pagesLeft / readingSpeed.pages_per_minute; + const hours = Math.floor(minutesLeft / 60); + const mins = Math.round(minutesLeft % 60); + return { + mode: "time-left", + text: `${hours}h ${mins}m`, + }; + } +} + +function cycleProgressMode(): void { + const modes: Array<"pages" | "chapter" | "percentage" | "time-left"> = [ + "pages", + "chapter", + "percentage", + "time-left", + ]; + const currentMode = getCurrentProgressMode(); + const currentIndex = modes.indexOf(currentMode); + const nextMode = modes[(currentIndex + 1) % modes.length]; + setProgressMode(nextMode); +} diff --git a/web/src/reader/reader-shell.ts b/web/src/reader/reader-shell.ts new file mode 100644 index 0000000..a89a91a --- /dev/null +++ b/web/src/reader/reader-shell.ts @@ -0,0 +1,392 @@ +// Universal Reader Shell - Routes to appropriate reader +// Procedural style: Functions, not classes + +import { Alpine } from "../alpine"; +import { getReaderMetadata, updateReadingProgress } from "./api"; +import { SettingsManager } from "./settings-manager"; +import { ProgressIndicator } from "./progress-indicator"; +import { parseEbook, requiresServerParsing } from "./parser-manager"; +import { initializePDFReader } from "./pdf/pdfjs-wrapper"; +import { initializeComicReader } from "./comic/image-parser"; + +// ============================================================ +// Reader State +// ============================================================ + +let currentReader: + | UniversalReader + | PDFReader + | ComicReader + | MangaReader + | null = null; +let readerMetadata: ReaderMetadata | null = null; + +interface UniversalReader { + type: "ebook"; + cif: EbookCIF; + currentSpineIndex: number; +} + +interface PDFReader { + type: "pdf"; + doc: any; + currentPage: number; +} + +interface ComicReader { + type: "comic"; + images: Blob[]; + currentPage: number; +} + +interface MangaReader { + type: "manga"; + images: Blob[]; + currentPage: number; + readingDirection: "rtl" | "vertical"; +} + +// ============================================================ +// Initialization +// ============================================================ + +async function initializeReader(): Promise { + const mediaItemId = document.body.dataset.mediaItemId; + if (!mediaItemId) return; + + // Fetch metadata + readerMetadata = await getReaderMetadata(mediaItemId); + + // Initialize appropriate reader based on type + switch (readerMetadata.library_type) { + case "ebook": + currentReader = await initializeEbookReader(readerMetadata); + break; + case "pdf": + currentReader = await initializePDFReader(readerMetadata); + break; + case "comic": + currentReader = await initializeComicReader(readerMetadata); + break; + case "manga": + currentReader = await initializeMangaReader(readerMetadata); + break; + } + + if (currentReader) { + setupReaderUI(); + } +} + +async function initializeEbookReader( + metadata: ReaderMetadata, +): Promise { + // Check if server-side parsing is needed + const needsServer = requiresServerParsing( + metadata.mime_type, + getFileExtension(metadata.file_path), + ); + + let ebookFile: Blob; + + if (needsServer) { + // Fetch parsed CIF from server + const response = await fetch(`/readers/${metadata.media_item_id}/parse`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mime_type: metadata.mime_type, + file_path: metadata.file_path, + }), + }); + + if (!response.ok) { + throw new Error(`Server parsing failed: ${response.statusText}`); + } + + ebookFile = await response.blob(); + } else { + // Fetch original file for client-side parsing + const response = await fetch(metadata.file_path); + ebookFile = await response.blob(); + } + + // Parse ebook to CIF + const cif = await parseEbook( + ebookFile, + metadata.mime_type, + getFileExtension(metadata.file_path), + ); + + return { + type: "ebook", + cif, + currentSpineIndex: 0, + }; +} + +// ============================================================ +// UI Setup +// ============================================================ + +function setupReaderUI(): void { + if (!currentReader || !readerMetadata) return; + + // Setup chrome + setupChromeBehavior(); + + // Setup progress indicator + setupProgressIndicator(); + + // Setup annotations + setupAnnotations(); + + // Setup keyboard navigation + setupKeyboardNavigation(); +} + +function setupChromeBehavior(): void { + const chrome = document.getElementById("reader-chrome"); + if (!chrome) return; + + // Auto-hide on scroll + let hideTimeout: NodeJS.Timeout; + + window.addEventListener("scroll", () => { + chrome.classList.add("visible"); + + clearTimeout(hideTimeout); + hideTimeout = setTimeout(() => { + chrome.classList.remove("visible"); + }, 2000); + }); + + // Toggle on tap (for touch devices) + chrome.addEventListener("click", () => { + chrome.classList.toggle("visible"); + }); +} + +function setupProgressIndicator(): void { + // Update progress based on reader type + if (!currentReader) return; + + if (currentReader.type === "ebook") { + updateEbookProgress(currentReader.cif, currentReader.currentSpineIndex); + } else if (currentReader.type === "pdf") { + updatePDFProgress( + currentReader.currentPage, + readerMetadata.total_pages || 0, + ); + } else if (currentReader.type === "comic" || currentReader.type === "manga") { + updateComicProgress(currentReader.currentPage, currentReader.images.length); + } +} + +function setupAnnotations(): void { + // Load existing highlights and notes + // Implementation depends on annotation system +} + +function setupKeyboardNavigation(): void { + document.addEventListener("keydown", (e) => { + if (!currentReader) return; + + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + nextPage(); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + previousPage(); + break; + } + }); +} + +// ============================================================ +// Navigation Functions +// ============================================================ + +function nextPage(): void { + if (!currentReader) return; + + if (currentReader.type === "ebook") { + nextSpineItem(); + } else if (currentReader.type === "pdf") { + nextPDFPage(); + } else if (currentReader.type === "comic" || currentReader.type === "manga") { + nextComicPage(); + } +} + +function previousPage(): void { + if (!currentReader) return; + + if (currentReader.type === "ebook") { + previousSpineItem(); + } else if (currentReader.type === "pdf") { + previousPDFPage(); + } else if (currentReader.type === "comic" || currentReader.type === "manga") { + previousComicPage(); + } +} + +function nextSpineItem(): void { + if (currentReader?.type !== "ebook") return; + + if (currentReader.currentSpineIndex < currentReader.cif.spine.length - 1) { + currentReader.currentSpineIndex++; + renderCurrentSpineItem(); + } +} + +function previousSpineItem(): void { + if (currentReader?.type !== "ebook") return; + + if (currentReader.currentSpineIndex > 0) { + currentReader.currentSpineIndex--; + renderCurrentSpineItem(); + } +} + +function renderCurrentSpineItem(): void { + if (currentReader?.type !== "ebook") return; + + const spineItem = currentReader.cif.spine[currentReader.currentSpineIndex]; + const container = document.getElementById("reader-content"); + + if (!container) return; + + // Render spine item content + container.innerHTML = spineItem.content; + + // Apply theme and typography + applyReaderTheme(); + applyTypography(); + + // Update progress + updateProgress(); +} + +// ============================================================ +// Progress Tracking +// ============================================================ + +function updateProgress(): void { + if (!currentReader || !readerMetadata) return; + + let percentage = 0; + let currentPosition = ""; + + if (currentReader.type === "ebook") { + const totalSpine = currentReader.cif.spine.length; + percentage = (currentReader.currentSpineIndex + 1) / totalSpine; + currentPosition = `spine:${currentReader.currentSpineIndex}`; + } else if (currentReader.type === "pdf") { + const totalPages = readerMetadata.total_pages || 1; + percentage = currentReader.currentPage / totalPages; + currentPosition = `page:${currentReader.currentPage}`; + } else if (currentReader.type === "comic" || currentReader.type === "manga") { + const totalPages = currentReader.images.length; + percentage = currentReader.currentPage / totalPages; + currentPosition = `page:${currentReader.currentPage}`; + } + + // Send to backend + updateReadingProgress(readerMetadata.media_item_id, { + percentage, + current_page: + currentReader.type === "ebook" + ? currentReader.currentSpineIndex + : currentReader.currentPage, + position: currentPosition, + }); +} + +// ============================================================ +// Alpine.js Integration +// ============================================================ + +Alpine.data("readerShell", () => ({ + init() { + initializeReader(); + }, + + nextPage, + previousPage, + + get currentPage() { + if (!currentReader) return 0; + + if (currentReader.type === "ebook") { + return currentReader.currentSpineIndex + 1; + } else { + return currentReader.currentPage; + } + }, + + get totalPages() { + if (!currentReader || !readerMetadata) return 0; + + if (currentReader.type === "ebook") { + return currentReader.cif.spine.length; + } else if (currentReader.type === "pdf") { + return readerMetadata.total_pages || 0; + } else { + return currentReader.images.length; + } + }, +})); + +// ============================================================ +// Utility Functions +// ============================================================ + +function getFileExtension(filepath: string): string { + const match = filepath.match(/\.([^.]+)$/); + return match ? `.${match[1]}` : ""; +} + +function applyReaderTheme(): void { + // Apply reading theme from settings + const settings = getReaderSettings(); + + const container = document.getElementById("reader-content"); + if (!container) return; + + container.className = `ebook-content theme-${settings.reading_theme}`; +} + +function applyTypography(): void { + const settings = getReaderSettings(); + const container = document.getElementById("reader-content"); + if (!container) return; + + container.style.fontSize = `${settings.font_size}px`; + container.style.lineHeight = settings.line_height.toString(); + container.style.fontFamily = getFontStack(settings.reading_font); +} + +function getFontStack(font: string): string { + const stacks: Record = { + literata: '"Literata", serif', + crimson: '"Crimson Text", serif', + "source-serif": '"Source Serif 4", serif', + "eb-garamond": '"EB Garamond", serif', + libertinus: '"Libertinus Serif", serif', + "noto-serif": '"Noto Serif", serif', + "charis-sil": '"Charis SIL", serif', + "ibm-plex": '"IBM Plex Serif", serif', + }; + + return stacks[font] || stacks["literata"]; +} + +function getReaderSettings(): ReaderSettings { + // Load from settings manager + return {} as ReaderSettings; // Simplified +} diff --git a/web/src/reader/settings-manager.ts b/web/src/reader/settings-manager.ts new file mode 100644 index 0000000..c2829a0 --- /dev/null +++ b/web/src/reader/settings-manager.ts @@ -0,0 +1,120 @@ +// Per-user settings with localStorage fallback + +import { apiGet, apiPut } from "../api"; +import { getToken, setItem, getItem } from "../storage"; + +const SETTINGS_KEY = "reader_settings"; +const LOCALSTORAGE_KEY = "reader_settings_local"; + +interface SettingsManager { + load(): Promise; + save(settings: Partial): Promise; + sync(): Promise; // Sync localStorage → DB + get(key: keyof ReaderSettings): any; + set(key: keyof ReaderSettings, value: any): Promise; +} + +async function loadSettings(): Promise { + const token = getToken(); + if (!token) { + // Fallback to localStorage + const local = getItem(LOCALSTORAGE_KEY); + return local ? JSON.parse(local) : getDefaultSettings(); + } + + try { + const response = await apiGet("/readers/settings"); + const settings = await response.json(); + // Cache in localStorage + setItem(LOCALSTORAGE_KEY, JSON.stringify(settings)); + return settings; + } catch (error) { + // Fallback to localStorage on error + const local = getItem(LOCALSTORAGE_KEY); + return local ? JSON.parse(local) : getDefaultSettings(); + } +} + +async function saveSettings(settings: Partial): Promise { + const token = getToken(); + if (!token) { + // Save to localStorage only + const current = loadSettings(); + const updated = { ...current, ...settings }; + setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); + return; + } + + try { + await apiPut("/readers/settings", settings); + // Update localStorage cache + const current = loadSettings(); + const updated = { ...current, ...settings }; + setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); + } catch (error) { + // Fallback to localStorage + const current = loadSettings(); + const updated = { ...current, ...settings }; + setItem(LOCALSTORAGE_KEY, JSON.stringify(updated)); + } +} + +function getDefaultSettings(): ReaderSettings { + return { + chrome_behavior: "auto-hide", + progress_mode: "pages", + chrome_theme: "tokyo-night", // UI chrome: All 11 themes available + reading_theme: "dark", // Ebook text: 5 reading-optimized themes + reading_font: "literata", // Default reading font (designed for ebooks) + tap_zone_size: 30, + auto_scroll: false, + panel_zoom_enabled: true, + font_size: 16, + line_height: 1.6, + margin_width: 20, + double_page_spread: false, + reading_direction: "ltr", + hardware_acceleration: true, + + // Dockable panel defaults by media type + panel_layout: { + toc: { + side: "left", + visible: true, + collapsed: false, + width_px: 320, + order: 1, + locked: false, + last_valid_side: "left", + }, + settings: { + side: "left", + visible: false, + collapsed: true, + width_px: 380, + order: 2, + locked: false, + last_valid_side: "left", + }, + navigator: { + side: "right", + visible: true, + collapsed: false, + width_px: 200, + order: 1, + locked: false, + last_valid_side: "right", + }, + bookmarks: { + side: "right", + visible: false, + collapsed: true, + width_px: 280, + order: 2, + locked: false, + last_valid_side: "right", + }, + mobile_nav_visible: false, + }, + }; +}