Implement core reader TypeScript modules for shell and UI management
- reader-shell.ts: Main initialization, Alpine.js integration, media type detection - progress-indicator.ts: Reading progress tracking and display components - settings-manager.ts: User settings persistence and retrieval - panel-dock-system.ts: Dockable panel management with drag/drop and collapse - parser-manager.ts: Parser selection and format detection system These core modules provide the foundation for all reader types with shared functionality for progress tracking, settings management, and the flexible panel docking system.
This commit is contained in:
@@ -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<string, PanelState>;
|
||||
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<void> {
|
||||
const settings = loadSettings();
|
||||
settings.panel_layout[panelId as keyof typeof settings.panel_layout] =
|
||||
panelState;
|
||||
await saveSettings(settings);
|
||||
}
|
||||
|
||||
export { initializePanelDockSystem, registerPanel, applyPanelState };
|
||||
@@ -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<EbookCIF> {
|
||||
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<EbookCIF> {
|
||||
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<EbookCIF> {
|
||||
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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<UniversalReader> {
|
||||
// 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<string, string> = {
|
||||
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
|
||||
}
|
||||
@@ -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<ReaderSettings>;
|
||||
save(settings: Partial<ReaderSettings>): Promise<void>;
|
||||
sync(): Promise<void>; // Sync localStorage → DB
|
||||
get(key: keyof ReaderSettings): any;
|
||||
set(key: keyof ReaderSettings, value: any): Promise<void>;
|
||||
}
|
||||
|
||||
async function loadSettings(): Promise<ReaderSettings> {
|
||||
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<ReaderSettings>): Promise<void> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user