feat: Add missing feature files for build

Create panel-dock-system.ts and navigator-panel.ts in features/
directory with proper init() implementations. Copy offline-manager
and reading-speed-tracker to features/ as well.
This commit is contained in:
2026-04-04 14:20:48 -04:00
parent 6649d0526d
commit bb586984d1
4 changed files with 497 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
// Navigator panel - shows full page with draggable viewport box
// Affinity/Photoshop-style mini-map for page navigation
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: NavigatorState | null = null;
context.events.on("navigator:initialize", (detail: { containerSelector: string; totalPages: number }) => {
state = initializeNavigator(detail.containerSelector);
state.totalPages = detail.totalPages;
});
context.events.on("navigator:update", (detail: { currentPage: number; contentImage?: HTMLImageElement }) => {
if (state) {
state.currentPage = detail.currentPage;
if (detail.contentImage) {
state.contentImage = detail.contentImage;
updateNavigatorViewport(state);
}
}
});
context.events.on("navigator:pan", (detail: { x: number; y: number }) => {
if (state) {
handleNavigatorPan(state, detail.x, detail.y);
}
});
}
interface NavigatorState {
panelId: string;
container: HTMLElement;
viewport: HTMLElement;
currentPage: number;
totalPages: number;
scale: number;
contentImage: HTMLImageElement | null;
isDragging: boolean;
}
function initializeNavigator(containerSelector: string): NavigatorState {
const container = document.querySelector(containerSelector) as HTMLElement;
if (!container) throw new Error("Navigator container not found");
const viewport = document.createElement("div");
viewport.className = "navigator-viewport-box";
viewport.style.cssText = `
position: absolute;
border: 2px solid var(--accent-color, #3b82f6);
background: rgba(59, 130, 246, 0.1);
cursor: move;
z-index: 10;
`;
container.appendChild(viewport);
const state: NavigatorState = {
panelId: "navigator",
container,
viewport,
currentPage: 1,
totalPages: 1,
scale: 0.1,
contentImage: null,
isDragging: false,
};
setupNavigatorDragHandler(state);
return state;
}
function setupNavigatorDragHandler(state: NavigatorState): void {
state.viewport.addEventListener("mousedown", (e) => {
e.preventDefault();
state.isDragging = true;
state.viewport.style.cursor = "grabbing";
});
document.addEventListener("mousemove", (e) => {
if (!state.isDragging || !state.contentImage) return;
const containerRect = state.container.getBoundingClientRect();
const imgRect = state.contentImage.getBoundingClientRect();
const relX = (e.clientX - imgRect.left) / imgRect.width;
const relY = (e.clientY - imgRect.top) / imgRect.height;
const mainViewer = document.getElementById("reader-content");
if (mainViewer) {
mainViewer.dispatchEvent(
new CustomEvent("navigator-pan", {
detail: { x: relX, y: relY },
}),
);
}
});
document.addEventListener("mouseup", () => {
if (state.isDragging) {
state.isDragging = false;
state.viewport.style.cursor = "move";
}
});
}
function updateNavigatorViewport(state: NavigatorState): void {
if (!state.contentImage) return;
const containerRect = state.container.getBoundingClientRect();
const imgRect = state.contentImage.getBoundingClientRect();
state.scale = containerRect.width / imgRect.width;
state.viewport.style.width = `${containerRect.width}px`;
state.viewport.style.height = `${containerRect.height * state.scale}px`;
state.viewport.style.left = "0";
state.viewport.style.top = `${(state.currentPage - 1) * imgRect.height * state.scale}px`;
}
function handleNavigatorPan(state: NavigatorState, x: number, y: number): void {
if (!state.contentImage) return;
const imgRect = state.contentImage.getBoundingClientRect();
const viewportX = x * state.container.offsetWidth;
const viewportY = y * state.container.offsetHeight;
state.viewport.style.left = `${viewportX}px`;
state.viewport.style.top = `${viewportY}px`;
}
@@ -0,0 +1,49 @@
// Offline manager for PWA functionality
// Feature Registration Pattern implementation
import type { ReaderContext } from "./core/reader-context";
export function init(context: ReaderContext): void {
registerServiceWorker();
window.addEventListener("online", () => {
const isOnline = checkOnlineStatus();
if (isOnline) {
context.events.emit("offline:online", {});
syncPendingChanges(context);
}
});
window.addEventListener("offline", () => {
context.events.emit("offline:offline", {});
});
context.events.on("offline:check", () => {
const isOnline = checkOnlineStatus();
context.events.emit("offline:status", { isOnline });
});
}
export function registerServiceWorker(): void {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/static/service-worker.js")
.then((registration) => {
console.log("Service worker registered:", registration);
})
.catch((error) => {
console.error("Service worker registration failed:", error);
});
}
}
export function checkOnlineStatus(): boolean {
if (typeof navigator !== "undefined" && navigator.onLine) {
return true;
}
return false;
}
function syncPendingChanges(context: ReaderContext): void {
context.events.emit("offline:sync", {});
}
@@ -0,0 +1,190 @@
// Modular dockable panel system - handles drag, lock, snap-back, window-shade
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const dockZones: DockZone[] = [
{ side: "left", x: 0, width: 400, height: window.innerHeight },
{
side: "right",
x: window.innerWidth - 400,
width: 400,
height: window.innerHeight,
},
];
const state: PanelDockState = {
panels: new Map(),
dragState: null,
dockZones,
};
context.events.on("panels:initialize", async () => {
const settings = await loadSettings();
for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
state.panels.set(panelId, panelState as PanelState);
createPanel(context, panelId, panelState as PanelState);
}
setupGlobalDragHandlers(context, state);
});
context.events.on("panel:toggle", (detail: { panelId: string }) => {
const panelState = state.panels.get(detail.panelId);
if (panelState) {
panelState.visible = !panelState.visible;
updatePanelVisibility(context, detail.panelId, panelState.visible);
}
});
context.events.on("panel:collapse", (detail: { panelId: string }) => {
const panelState = state.panels.get(detail.panelId);
if (panelState) {
panelState.collapsed = !panelState.collapsed;
updatePanelCollapsed(context, detail.panelId, panelState.collapsed);
}
});
context.events.on("panel:move", (detail: { panelId: string; side: "left" | "right" }) => {
movePanelToSide(context, state, detail.panelId, detail.side);
});
}
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;
}
interface PanelState {
side: "left" | "right";
visible: boolean;
collapsed: boolean;
width_px: number;
order: number;
locked: boolean;
last_valid_side: "left" | "right";
}
async function loadSettings(): Promise<any> {
const response = await fetch("/readers/settings");
if (!response.ok) return {};
return await response.json();
}
function createPanel(context: ReaderContext, panelId: string, panelState: PanelState): void {
const panel = document.createElement("div");
panel.id = `panel-${panelId}`;
panel.className = `reader-panel panel-${panelState.side}`;
panel.dataset.panelId = panelId;
panel.style.width = `${panelState.width_px}px`;
if (!panelState.visible) {
panel.classList.add("panel-hidden");
}
if (panelState.collapsed) {
panel.classList.add("panel-collapsed");
}
context.elements.readerContent.appendChild(panel);
}
function updatePanelVisibility(context: ReaderContext, panelId: string, visible: boolean): void {
const panel = document.getElementById(`panel-${panelId}`);
if (panel) {
panel.classList.toggle("panel-hidden", !visible);
}
}
function updatePanelCollapsed(context: ReaderContext, panelId: string, collapsed: boolean): void {
const panel = document.getElementById(`panel-${panelId}`);
if (panel) {
panel.classList.toggle("panel-collapsed", collapsed);
}
}
function setupGlobalDragHandlers(context: ReaderContext, state: PanelDockState): void {
document.addEventListener("mousedown", (e) => {
const panelHeader = (e.target as HTMLElement).closest(".panel-header");
if (panelHeader) {
const panelId = (panelHeader as HTMLElement).dataset.panelId;
if (panelId) {
const panelState = state.panels.get(panelId);
if (panelState && !panelState.locked) {
state.dragState = {
panelId,
startX: e.clientX,
startY: e.clientY,
currentX: e.clientX,
currentY: e.clientY,
isLocked: false,
};
}
}
}
});
document.addEventListener("mousemove", (e) => {
if (!state.dragState) return;
state.dragState.currentX = e.clientX;
state.dragState.currentY = e.clientY;
});
document.addEventListener("mouseup", () => {
if (state.dragState) {
const nearestZone = findNearestDockZone(state.dragState.currentX, state.dockZones);
if (nearestZone) {
movePanelToSide(context, state, state.dragState.panelId, nearestZone.side);
}
state.dragState = null;
}
});
}
function findNearestDockZone(x: number, zones: DockZone[]): DockZone | null {
let nearest: DockZone | null = null;
let minDistance = Infinity;
for (const zone of zones) {
const distance = Math.abs(x - (zone.x + zone.width / 2));
if (distance < minDistance) {
minDistance = distance;
nearest = zone;
}
}
return nearest;
}
function movePanelToSide(context: ReaderContext, state: PanelDockState, panelId: string, side: "left" | "right"): void {
const panelState = state.panels.get(panelId);
if (!panelState) return;
panelState.side = side;
panelState.last_valid_side = side;
const panel = document.getElementById(`panel-${panelId}`);
if (panel) {
panel.classList.remove("panel-left", "panel-right");
panel.classList.add(`panel-${side}`);
}
context.events.emit("panel:moved", { panelId, side });
}
@@ -0,0 +1,125 @@
// Track reading speed and update database
// Feature Registration Pattern implementation
import type { ReaderContext } from "./core/reader-context";
export function init(context: ReaderContext): void {
let state: ReadingSpeedTrackerState | null = null;
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
state = createReadingSpeedTracker(detail.mediaItemId);
});
context.events.on("reading-session:start", () => {
if (state) {
startReadingSession(state);
}
});
context.events.on("page-changed", () => {
if (state) {
recordPageTurn(state);
}
});
context.events.on("words-read", (detail: { wordCount: number }) => {
if (state) {
recordWordsRead(state, detail.wordCount);
}
});
context.events.on("reading-session:end", async () => {
if (state) {
await syncReadingSpeed(state);
}
});
context.events.on("reader:unload", async () => {
if (state) {
await syncReadingSpeed(state);
state = null;
}
});
}
interface ReadingSpeedTrackerState {
startTime: number | null;
pagesRead: number;
wordsRead: number;
lastSync: number;
mediaItemId: string;
}
function createReadingSpeedTracker(
mediaItemId: string,
): ReadingSpeedTrackerState {
return {
startTime: null,
pagesRead: 0,
wordsRead: 0,
lastSync: Date.now(),
mediaItemId,
};
}
function startReadingSession(
state: ReadingSpeedTrackerState,
): ReadingSpeedTrackerState {
state.startTime = Date.now();
state.pagesRead = 0;
state.wordsRead = 0;
return state;
}
function recordPageTurn(
state: ReadingSpeedTrackerState,
): ReadingSpeedTrackerState {
if (!state.startTime) return state;
state.pagesRead += 1;
const now = Date.now();
if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
syncReadingSpeed(state);
state.lastSync = now;
}
return state;
}
function recordWordsRead(
state: ReadingSpeedTrackerState,
wordCount: number,
): ReadingSpeedTrackerState {
state.wordsRead += wordCount;
return state;
}
async function syncReadingSpeed(
state: ReadingSpeedTrackerState,
): Promise<void> {
if (!state.startTime) return;
const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60);
const pagesPerMinute = state.pagesRead / minutesElapsed;
const wordsPerMinute = state.wordsRead / minutesElapsed;
try {
const token = localStorage.getItem("token");
await fetch(`/readers/${state.mediaItemId}/reading-speed`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
pages_per_minute: pagesPerMinute,
words_per_minute: wordsPerMinute,
pages_read: state.pagesRead,
total_reading_minutes: minutesElapsed,
}),
});
} catch (error) {
console.error("Failed to sync reading speed:", error);
}
}