Initial commit

This commit is contained in:
hiperman
2025-12-04 00:33:37 -05:00
commit 7ca0a21283
798 changed files with 190424 additions and 0 deletions
@@ -0,0 +1,354 @@
<script lang="ts">
// TODO: Add type hints to the rest of this file
import { browser } from '$app/environment';
import { onDestroy, onMount } from 'svelte';
import { Book, Rendition } from 'epubjs';
import '../../../app.css';
import * as Sidebar from '$lib/components/ui/sidebar/index';
import * as Collapsible from '$lib/components/ui/collapsible/index';
import { ChevronDown, ChevronRight, ChevronLeft } from '@lucide/svelte';
import { Spinner } from '$lib/components/ui/spinner/index';
import type { DisplayedLocation } from 'epubjs/types/rendition';
let { bookUrl, bookId, initialProgress = 0, initialEpubLoc = null } = $props();
let epubViewer = $state<HTMLElement>();
let containerWidth = $state(0);
let containerHeight = $state(0);
let isReaderVisible = $state(false);
let hasNextPage = $state(true);
let hasPrevPage = $state(false);
let book: Book | undefined = $state();
let rendition = $state<Rendition>();
let chapters = $state([]);
let isMounted = $state(false);
let currentLocation = $state(initialEpubLoc);
let currentProgress = $state(initialProgress);
let isSidebarOpen = $state(false);
let debounceTimeout = $state<NodeJS.Timeout>();
// Function to update dimensions
function updateDimensions() {
if (epubViewer) {
// Get parent element dimensions
const parent = epubViewer.parentElement;
containerWidth = parent?.clientWidth! * 0.9 - 240;
containerHeight = window.innerHeight * 0.8;
}
}
// Handle window resize
function handleResize() {
updateDimensions();
if (rendition) {
rendition.resize(containerWidth, containerHeight);
}
}
async function handleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') {
await prevPage();
} else if (e.key === 'ArrowRight') {
await nextPage();
}
}
async function nextPage() {
if (hasNextPage) await rendition!.next();
}
async function prevPage() {
if (hasPrevPage) await rendition!.prev();
}
async function setUserBookProgress() {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(async () => {
await fetch(`/api/books/progress/${bookId}`, {
method: 'POST',
body: JSON.stringify({
progress: currentProgress,
epub_loc: currentLocation,
completed: currentProgress === 1
})
});
}, 3000);
}
const getChapters = async (book: Book) => {
await book.ready;
// Get spine items (basic chapter structure)
const spineItems = book.spine.items.map((item, index) => ({
id: item.idref,
href: item.href,
index: index,
label: item.label || `Chapter ${index + 1}`,
cfi: book.spine.get(index).cfiBase // Get CFI from spine
}));
// Get table of contents for better labels
const toc = await book.loaded.navigation;
// Combine spine items with TOC information
const chapters = toc.toc.map((chapter) => {
const spineItem = spineItems.find((item: any) => item.href === chapter.href);
return {
...spineItem,
label: chapter.label || spineItem?.label,
subitems: chapter.subitems,
href: chapter.href,
cfi: spineItem?.cfi || book.spine.get(chapter.href)?.cfiBase
};
});
return chapters;
};
async function navigateToChapter(chapter: any) {
try {
if (!rendition || !book) return;
// Ensure book is ready
await book.ready;
// Try different navigation methods
if (chapter.href) {
await rendition.display(chapter.href);
} else {
console.error('No valid navigation target found for chapter:', chapter);
}
} catch (error) {
console.error('Error navigating to chapter:', error);
}
}
onMount(async () => {
if (browser) {
// Initial setup
updateDimensions();
try {
// Fetch the EPUB file
const response = await fetch(bookUrl);
const arrayBuffer = await response.arrayBuffer();
// Create book from array buffer
book = new Book();
await book.open(arrayBuffer, 'binary');
// Generate locations if they do not exist in localStorage
let existingLocations = localStorage.getItem(`${bookId}-locations`);
let locations;
if (existingLocations) {
locations = JSON.parse(existingLocations);
book.locations.load(locations);
} else {
locations = await book.locations.generate(1600);
// Save locations to localStorage
localStorage.setItem(`${bookId}-locations`, JSON.stringify(locations));
}
await book.ready;
// Render the book to the viewer element
rendition = book.renderTo('epub-viewer', {
width: containerWidth,
height: containerHeight,
spread: 'auto',
snap: true,
manager: 'continuous',
flow: 'paginated'
});
// Set the key listener on the iframe element
let keyListener = async function (e: any) {
// Left Key
if ((e.keyCode || e.which) == 37) {
await prevPage();
}
// Right Key
if ((e.keyCode || e.which) == 39) {
await nextPage();
}
};
// Add resize listener
window.addEventListener('resize', handleResize);
// Add Key listener
rendition.on('keydown', keyListener);
// Listen to location changes
rendition.on('locationChanged', async (location: DisplayedLocation) => {
if (!location?.start) return;
currentLocation = rendition!.currentLocation().start.cfi;
currentProgress = book?.locations.percentageFromCfi(currentLocation);
await setUserBookProgress();
hasNextPage = !rendition!.location.atEnd;
hasPrevPage = !rendition!.location.atStart;
});
chapters = await getChapters(book);
let initialLocationCfi =
currentLocation || book.locations.cfiFromPercentage(currentProgress);
if (initialLocationCfi) {
await rendition.display(initialLocationCfi);
} else {
await rendition.display();
}
isMounted = true;
isReaderVisible = true;
} catch (error) {
console.error('Error loading EPUB', error);
}
}
});
onDestroy(() => {
if (browser) {
window.removeEventListener('resize', handleResize);
book.destroy();
}
});
</script>
<svelte:document onkeydown={handleKeydown} />
<Sidebar.Provider bind:open={isSidebarOpen}>
<Sidebar.Root class={!isReaderVisible ? 'hidden' : ''}>
<Sidebar.Header />
<Sidebar.Content>
<Sidebar.GroupLabel>Chapters</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each chapters as chapter}
{#if chapter.subitems.length > 0}
<Collapsible.Root class="group/collapsible">
<div class="flex w-full items-center gap-1">
<Sidebar.MenuItem class="min-w-0 flex-1">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(chapter)}
>
<span class="block truncate" title={chapter.label}>{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Collapsible.Trigger class="flex-shrink-0 p-2">
<ChevronDown
class="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180"
/>
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each chapter.subitems as subchapter}
<Sidebar.MenuSubItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(subchapter)}
>
<span class="block truncate" title={subchapter.label}
>{subchapter.label}</span
>
</Sidebar.MenuButton>
</Sidebar.MenuSubItem>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Collapsible.Root>
{:else}
<Sidebar.MenuItem class="min-w-0">
<Sidebar.MenuButton
class="w-full"
onclick={async () => await navigateToChapter(chapter)}
>
<span class="block truncate">{chapter.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
<Sidebar.Group />
</Sidebar.Content>
<Sidebar.Footer />
</Sidebar.Root>
<main class="flex w-full overflow-hidden">
{#if browser}
{#if !isReaderVisible}
<div class="absolute inset-0 flex items-center justify-center">
<Spinner />
</div>
{/if}
<div class="h-full w-full {isReaderVisible ? '' : 'opacity-0'}">
<div class="flex">
<Sidebar.Trigger />
</div>
<div class="mt-[-30px] flex h-full w-full items-center justify-center">
<ChevronLeft
onclick={prevPage}
class={hasPrevPage
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
: 'text-muted'}
/>
<div
id="epub-viewer"
bind:this={epubViewer}
class="h-[{containerHeight}px] w-[{containerWidth}] epub-content mx-8 rounded border-2 p-8 shadow-md"
></div>
<ChevronRight
onclick={nextPage}
class={hasNextPage
? 'text-primary/70 hover:cursor-pointer hover:text-primary'
: 'text-muted'}
/>
</div>
</div>
{/if}
</main>
</Sidebar.Provider>
<style>
.epub-content {
position: relative;
background: white;
}
/* Position separator relative to epub content */
@media (min-width: 1209px) {
.epub-content:after {
/* Calculate position based on content width */
--separator-position: calc(var(--content-width, 100%) / 2);
position: absolute;
width: 1px;
border-right: 1px #000 solid;
height: 90%;
z-index: 1;
left: 50%;
transform: translateX(-50%);
top: 5%;
opacity: 0.15;
box-shadow: -2px 0 15px rgba(0, 0, 0, 1);
content: '';
}
}
</style>