Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
424
frontend/src/routes/read/[id]/+page.svelte
Normal file
424
frontend/src/routes/read/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { siteSettings } from '$lib/stores/siteSettings';
|
||||
|
||||
let readerContainer;
|
||||
let ebook = null;
|
||||
let loading = true;
|
||||
let error = null;
|
||||
let book = null;
|
||||
let rendition = null;
|
||||
let toc = [];
|
||||
let currentLocation = null;
|
||||
let showToc = false;
|
||||
let progress = 0;
|
||||
|
||||
$: ebookId = $page.params.id;
|
||||
|
||||
async function loadEbook() {
|
||||
if (!browser || !ebookId) return;
|
||||
|
||||
try {
|
||||
// Fetch ebook metadata
|
||||
const metaRes = await fetch(`/api/ebooks/${ebookId}`);
|
||||
if (!metaRes.ok) {
|
||||
if (metaRes.status === 404) {
|
||||
error = 'Ebook not found';
|
||||
} else {
|
||||
error = 'Failed to load ebook';
|
||||
}
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await metaRes.json();
|
||||
ebook = data.ebook;
|
||||
|
||||
// Increment read count
|
||||
fetch(`/api/ebooks/${ebookId}/read`, { method: 'POST' }).catch(() => {});
|
||||
|
||||
// Dynamically import foliate-js to avoid SSR issues
|
||||
const { EPUB } = await import('foliate-js/epub.js');
|
||||
|
||||
// Fetch the EPUB file
|
||||
const fileRes = await fetch(ebook.filePath);
|
||||
if (!fileRes.ok) {
|
||||
error = 'Failed to load ebook file';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await fileRes.blob();
|
||||
|
||||
// Initialize reader
|
||||
book = await new EPUB({ blob }).init();
|
||||
toc = book.toc || [];
|
||||
|
||||
if (readerContainer) {
|
||||
// Create the view
|
||||
rendition = book.render(readerContainer);
|
||||
|
||||
// Restore saved position
|
||||
const savedPosition = localStorage.getItem(`ebook-pos-${ebookId}`);
|
||||
if (savedPosition) {
|
||||
try {
|
||||
rendition.goTo(savedPosition);
|
||||
} catch (e) {
|
||||
console.warn('Could not restore position:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for location changes
|
||||
rendition.addEventListener('relocate', (e) => {
|
||||
currentLocation = e.detail;
|
||||
progress = e.detail.fraction ? Math.round(e.detail.fraction * 100) : 0;
|
||||
// Save position
|
||||
if (e.detail.cfi) {
|
||||
localStorage.setItem(`ebook-pos-${ebookId}`, e.detail.cfi);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
console.error('Failed to load ebook:', e);
|
||||
error = 'Failed to load ebook: ' + e.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToTocItem(href) {
|
||||
if (rendition && href) {
|
||||
rendition.goTo(href);
|
||||
showToc = false;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (rendition) {
|
||||
rendition.prev();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (rendition) {
|
||||
rendition.next();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
prevPage();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
nextPage();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadEbook();
|
||||
if (browser) {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
if (book) {
|
||||
book.destroy?.();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{ebook ? `${$siteSettings.site_title} - ${ebook.title}` : $siteSettings.site_title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
.reader-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.reader-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.book-title {
|
||||
font-size: 0.9rem;
|
||||
color: var(--white);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
font-size: 0.85rem;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toc-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
border-radius: 6px;
|
||||
color: #3b82f6;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toc-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.reader-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reader-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.reader-container :global(*) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 50px;
|
||||
height: 100px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.reader-main:hover .nav-btn {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.nav-prev {
|
||||
left: 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.nav-next {
|
||||
right: 0;
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.toc-sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
background: #111;
|
||||
border-left: 1px solid #333;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toc-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.toc-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.toc-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toc-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--gray);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toc-item:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-state h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.keyboard-hint {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #666;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="reader-page">
|
||||
<div class="reader-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<a href="/ebooks" class="back-btn">
|
||||
<span>←</span> Back
|
||||
</a>
|
||||
{#if ebook}
|
||||
<span class="book-title">{ebook.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span class="progress-indicator">{progress}%</span>
|
||||
{#if toc.length > 0}
|
||||
<button class="toc-btn" on:click={() => showToc = !showToc}>
|
||||
Contents
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-main">
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<p>Loading ebook...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<h2>Error</h2>
|
||||
<p>{error}</p>
|
||||
<a href="/ebooks" class="back-btn" style="margin-top: 1rem;">
|
||||
Return to library
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="nav-btn nav-prev" on:click={prevPage} title="Previous page">‹</button>
|
||||
|
||||
<div bind:this={readerContainer} class="reader-container"></div>
|
||||
|
||||
<button class="nav-btn nav-next" on:click={nextPage} title="Next page">›</button>
|
||||
|
||||
<div class="toc-sidebar" class:open={showToc}>
|
||||
<div class="toc-header">
|
||||
<h3>Table of Contents</h3>
|
||||
<button class="toc-close" on:click={() => showToc = false}>×</button>
|
||||
</div>
|
||||
<div class="toc-list">
|
||||
{#each toc as item}
|
||||
<button class="toc-item" on:click={() => goToTocItem(item.href)}>
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keyboard-hint">
|
||||
Use ← → arrow keys to navigate
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue