upload to github

This commit is contained in:
jrosh 2025-05-29 13:41:38 +02:00
commit 92cc81e813
No known key found for this signature in database
GPG key ID: A4D68DCA6C9CCD2D
51 changed files with 8041 additions and 0 deletions

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
#VITE_REMOTE_API='true'
#VITE_API_URL=http://localhost
VITE_BASE_URL='/e-inn-reader/'
VITE_APP_NAME=Reader
VITE_PORT=5174

63
.github/workflows/static.yaml vendored Normal file
View file

@ -0,0 +1,63 @@
name: Deploy to GitHub Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows running this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued while one is in progress
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
#- name: Type check
# run: npm run type-check
- name: Build
run: npm run build-only
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: './dist'
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

31
.gitignore vendored Normal file
View file

@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
.env
/cypress/videos/
/cypress/screenshots/
ios/
android/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 jrosh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

55
README.md Normal file
View file

@ -0,0 +1,55 @@
# E-inn
E-inn is a basic EPUB reader supporting remote API imports and local file uploads. Books are stored in IndexedDB; settings and reading progress are retained via localStorage. Built as a single-page application with Vue.js, Vue Router, and epub.js for rendering. No accounts, sync, or advanced features, serves static assets. Minimal UI, lightweight, customizable interface.
## Running
Run development server with:
```
docker-compose up dev
```
Run clean build with:
```
docker-compose up build
```
### API reasoning:
If you have a userbase, you can use a centralized database for your content and the e-inn-reader as client. You only need to set some environment variables in the .env and build this project
```
VITE_REMOTE_API='true'
VITE_API_URL=http://localhost/api # your server
```
# Api server requirements
### `GET` `/api/epub-library`
Returns: `JSON`
```json
{
"epubs": [
{
"id": "id",
"filename": "book-title.epub",
"path": "/storage/path",
"size": 4521120
}
],
"total": 15
}
```
#### `GET` `/api/epub/{id}/{filename}`
Parameters:
- `id`: `string`
- `filename`: `string`
## Return: Binary EPUB file with Content-Type: application/epub+zip
```
OPTIONS /api/epub-library
OPTIONS /api/epub/{id}/{filename}
Returns: Empty response with CORS headers
```

18
docker-compose.yaml Normal file
View file

@ -0,0 +1,18 @@
version: '3.8'
services:
dev:
image: node:alpine
working_dir: /app
volumes:
- ./:/app
ports:
- "${VITE_PORT}:${VITE_PORT}"
command: sh -c "npm install && npm run dev"
build:
image: node:alpine
working_dir: /app
volumes:
- ./:/app
command: sh -c "rm -rf dist/ && npm install && npm run build-only"

12
env.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_REMOTE_API: boolean;
readonly VITE_API_URL: string;
readonly VITE_APP_NAME: string;
readonly VITE_PORT: number;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3463
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "e-inn-reader",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"epubjs": "^0.3.93",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.1",
"@types/node": "^22.14.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"npm-run-all2": "^7.0.2",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
}
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

BIN
public/fonts/Fast_Mono.ttf Normal file

Binary file not shown.

BIN
public/fonts/Fast_Sans.ttf Normal file

Binary file not shown.

Binary file not shown.

BIN
public/fonts/Fast_Serif.ttf Normal file

Binary file not shown.

7
src/App.vue Executable file
View file

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

73
src/assets/base.css Executable file
View file

@ -0,0 +1,73 @@
:root {
/* custom colors
--text-color
--background-color
--accent-color
*/
--muted-text: color-mix(in srgb, var(--text-color) 60%, transparent);
--divider-color: color-mix(in srgb, var(--text-color) 10%, transparent);
--green: #0a0;
--red: #a00;
--blue: #00c;
}
/* reset.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
line-height: 1.5;
}
ul, ol {
list-style: none;
}
a {
text-decoration: none;
color: inherit;
}
table {
border-collapse: collapse;
width: 100%;
}
img {
max-width: 100%;
height: auto;
display: block;
}
button {
border: none;
background: none;
cursor: pointer;
}
input, textarea, select {
font-family: inherit;
font-size: inherit;
}
h1, h2, h3, h4, h5, h6 {
font-weight: normal;
}
blockquote {
quotes: none;
}
blockquote:before, blockquote:after {
content: '';
}
hr {
border: none;
border-top: 1px solid var(--divider-color);
margin: 1em 0;
}

41
src/assets/main.css Executable file
View file

@ -0,0 +1,41 @@
@import './base.css';
#app{
width: 100%;
height: 100%;
background-color: var(--background-color);
}
body {
font-family: var(--font-family) !important;
line-height: 1.5;
background-color: var(--background-color);
}
h1, h2, h3, h4, h5, h6 {
color: var(--primary-text);
}
h1 {
font-size: 2.5em;
}
h2 {
font-size: 2em;
}
h3 {
font-size: 1.75em;
}
h4 {
font-size: 1.5em;
}
h5 {
font-size: 1.25em;
}
h6 {
font-size: 1em;
}

245
src/components/BookCard.vue Normal file
View file

@ -0,0 +1,245 @@
<!-- src/components/BookCard.vue -->
<template>
<div
class="book-card"
>
<div class="book-content" @click="$emit('read', book)">
<div class="book-cover">
<div class="book-icon">📚</div>
<div v-if="book.isLocal" class="book-badge">
{{ t('library.local') }}
</div>
</div>
<div class="book-info">
<h3 class="book-title">{{ displayTitle }}</h3>
<div class="book-details">
<!-- Size information (if available) -->
<div v-if="book.size" class="detail-row">
<span class="detail-label">{{ t('library.size') }}:</span>
<span class="detail-value">{{ formattedSize }}</span>
</div>
<!-- Date information -->
<div v-if="dateAdded" class="detail-row">
<span class="detail-label">{{ t('library.added') }}:</span>
<span class="detail-value">{{ dateAdded }}</span>
</div>
</div>
</div>
</div>
<!-- Settings button for local books only -->
<button
v-if="book.isLocal"
@click.stop="settingsModalOpen = true"
class="book-settings-button"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
</button>
<BookSettingsModal
:book="book"
:is-open="settingsModalOpen"
@close="settingsModalOpen = false"
@book-updated="handleBookUpdated"
@book-deleted="handleBookDeleted"
/>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, ref } from 'vue';
import { useI18n } from '../i18n/usei18n';
import { type EpubFile } from '../types/epubFile';
import { formatFilename, formatFileSize, formatDate } from '../utils/utils';
import BookSettingsModal from './BookSettingsModal.vue';
export default defineComponent({
name: 'BookCard',
components: {
BookSettingsModal
},
props: {
book: {
type: Object as () => EpubFile,
required: true
}
},
emits: ['read', 'book-updated', 'book-deleted'],
setup(props, { emit }) {
const { t } = useI18n();
const settingsModalOpen = ref(false);
const displayTitle = computed(() => {
return props.book.title || formatFilename(props.book.filename);
});
const formattedSize = computed(() => {
return props.book.size ? formatFileSize(props.book.size) : t('library.unknown');
});
const dateAdded = computed(() => {
return props.book.dateAdded ? formatDate(props.book.dateAdded) : null;
});
const handleBookUpdated = (updatedBook: EpubFile) => {
emit('book-updated', updatedBook);
};
const handleBookDeleted = (bookId: string) => {
emit('book-deleted', bookId);
};
return {
t,
displayTitle,
formattedSize,
dateAdded,
settingsModalOpen,
handleBookUpdated,
handleBookDeleted
};
}
});
</script>
<style scoped>
.book-card {
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid var(--divider-color);
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
position: relative;
height: 100%;
max-width: 100%;
}
.book-card:hover,
.book-card:focus {
transform: scale(1.03);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
outline: none;
}
.book-card:active {
transform: scale(0.98);
}
.book-content {
display: flex;
flex-direction: column;
height: 100%;
cursor: pointer;
}
/* Book Cover */
.book-cover {
background-color: color-mix(in srgb, var(--accent-color) 10%, transparent);
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
position: relative;
border-bottom: 1px solid var(--divider-color);
}
.book-icon {
font-size: 36px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.book-badge {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
background-color: var(--accent-color);
color: white;
}
/* Book Info Section */
.book-info {
padding: 1rem;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.book-title {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
}
.book-details {
color: var(--muted-text);
font-size: 14px;
flex-grow: 1;
}
.detail-row {
display: flex;
margin-bottom: 4px;
line-height: 1.3;
}
.detail-label {
font-weight: 500;
margin-right: 6px;
}
.detail-value {
flex: 1;
text-align: right;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
max-height: 2.6em; /* Based on line-height to ensure it stays contained */
line-height: 1.3;
}
.book-actions {
display: flex;
padding: 10px;
border-top-width: 1px;
border-top-style: solid;
border-top-color: var(--divider-color);
justify-content: space-around;
margin-top: auto;
}
.book-settings-button {
color: var(--muted-text);
position: absolute;
top: 8px;
left: 8px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 50%;
opacity: 0.7;
transition: opacity 0.2s, background-color 0.2s;
z-index: 5;
}
.book-settings-button:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -0,0 +1,378 @@
<!-- src/components/BookSettingsModal.vue -->
<template>
<Teleport to="body">
<div v-if="isOpen" class="modal-overlay">
<div
class="modal-content"
@click.stop
>
<div class="modal-header">
<h3>{{ t('settings.bookSettings') }}</h3>
<button class="close-button" @click="$emit('close')">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41z"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="bookTitle">{{ t('library.title') }}</label>
<input
id="bookTitle"
v-model="localTitle"
type="text"
:placeholder="t('library.enter_title')"
/>
</div>
<div class="book-info">
<div class="info-item">
<span class="info-label">{{ t('library.filename') }}:</span>
<span class="info-value">{{ book.filename }}</span>
</div>
<div v-if="book.size" class="info-item">
<span class="info-label">{{ t('library.size') }}:</span>
<span class="info-value">{{ formattedSize }}</span>
</div>
<div v-if="dateAdded" class="info-item">
<span class="info-label">{{ t('library.added') }}:</span>
<span class="info-value">{{ dateAdded }}</span>
</div>
</div>
</div>
<div class="modal-footer">
<button
class="delete-button"
@click="confirmDelete"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M14 11v6m-4-6v6M6 7v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7M4 7h16M7 7l2-4h6l2 4" stroke-width="1"/></svg>
{{ t('settings.deleteBook') }}
</button>
<button
class="save-button"
@click="saveChanges"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M5.616 20q-.691 0-1.153-.462T4 18.384V5.616q0-.691.463-1.153T5.616 4h10.29q.323 0 .628.13q.305.132.522.349l2.465 2.465q.218.218.348.522q.131.305.131.628v10.29q0 .691-.462 1.154T18.384 20zM19 7.85L16.15 5H5.616q-.27 0-.443.173T5 5.616v12.769q0 .269.173.442t.443.173h12.769q.269 0 .442-.173t.173-.443zm-7 8.689q.827 0 1.414-.587T14 14.538t-.587-1.413T12 12.539t-1.413.586T10 14.538t.587 1.414t1.413.586M7.577 9.77h5.808q.348 0 .578-.23t.23-.577V7.577q0-.348-.23-.578t-.578-.23H7.577q-.348 0-.578.23t-.23.578v1.385q0 .348.23.578t.578.23M5 7.85V19V5z"/></svg>
{{ t('settings.saveChanges') }}
</button>
</div>
<!-- Confirmation Dialog -->
<div v-if="showDeleteConfirm" class="confirm-dialog" @click.stop>
<div class="confirm-content">
<p>{{ t('settings.confirmDelete') }} <strong>"{{ book.title || formatFilename(book.filename) }}"</strong>?</p>
<div class="confirm-buttons">
<button
@click="showDeleteConfirm = false"
>
{{ t('settings.cancel') }}
</button>
<button
@click="deleteBook"
>
{{ t('settings.delete') }}
</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script lang="ts">
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
import { useI18n } from '../i18n/usei18n';
import { type EpubFile } from '../types/epubFile';
import { formatFilename, formatFileSize, formatDate } from '../utils/utils';
import { ImportBookService } from '../services/importBookService';
import { useStyles } from '../composables/useStyles';
export default defineComponent({
name: 'BookSettingsModal',
props: {
book: {
type: Object as () => EpubFile,
required: true
},
isOpen: {
type: Boolean,
default: false
}
},
emits: ['close', 'book-updated', 'book-deleted'],
setup(props, { emit }) {
const { t } = useI18n();
const { textColor, backgroundColor, accentColor, fontFamily } = useStyles();
const localTitle = ref('');
const showDeleteConfirm = ref(false);
const importBookService = new ImportBookService();
// Initialize local title with book title
onMounted(() => {
localTitle.value = props.book.title || formatFilename(props.book.filename);
});
// Update local title when book changes
watch(() => props.book, (newBook) => {
localTitle.value = newBook.title || formatFilename(newBook.filename);
});
// Add event listener to handle escape key
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
} else {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
}
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (showDeleteConfirm.value) {
showDeleteConfirm.value = false;
} else {
emit('close');
}
}
};
// Format size for display using utility function
const formattedSize = computed(() => {
return props.book.size ? formatFileSize(props.book.size) : t('library.unknown');
});
// Format date for display using utility function
const dateAdded = computed(() => {
return props.book.dateAdded ? formatDate(props.book.dateAdded) : null;
});
// Save changes to the book title
const saveChanges = async () => {
try {
if (localTitle.value !== props.book.title) {
// Create an updated book object
const updatedBook: EpubFile = {
...props.book,
title: localTitle.value
};
await importBookService.storeBook(updatedBook);
emit('book-updated', updatedBook);
}
emit('close');
} catch (err) {
console.error('Error updating book:', err);
}
};
// Show delete confirmation dialog
const confirmDelete = () => {
showDeleteConfirm.value = true;
};
// Delete the book from IndexedDB
const deleteBook = async () => {
try {
await importBookService.deleteBook(props.book.id);
emit('book-deleted', props.book.id);
emit('close');
} catch (err) {
console.error('Error deleting book:', err);
}
};
// Clean up event listeners when component is unmounted
onMounted(() => {
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = ''; // Ensure we reset overflow if component unmounts
};
});
return {
t,
textColor,
backgroundColor,
accentColor,
fontFamily,
localTitle,
formattedSize,
dateAdded,
showDeleteConfirm,
saveChanges,
confirmDelete,
deleteBook,
formatFilename
};
}
});
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
width: 90%;
max-width: 500px;
background-color: var(--background-color);
color: var(--text-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 1px solid var(--divider-color);
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.modal-header {
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--divider-color);
}
.modal-header h3 {
margin: 0;
font-size: 1.5em;
font-weight: bold;
}
.close-button {
background: none;
border: none;
cursor: pointer;
display: flex;
padding: 4px;
color: inherit;
}
.modal-body {
padding: 16px;
flex-grow: 1;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid var(--divider-color);
font-size: 16px;
}
.book-info {
margin-top: 24px;
}
.info-item {
margin-bottom: 8px;
display: flex;
}
.info-value {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
max-height: 2.6em; /* Based on line-height to ensure it stays contained */
line-height: 1.3;
}
.info-label {
font-weight: 500;
margin-right: 8px;
min-width: 80px;
}
.modal-footer {
padding: 16px;
display: flex;
justify-content: space-between;
gap: 12px;
border-top: 1px solid var(--divider-color);
}
.save-button{background: var(--green);}
.delete-button{background: var(--red);}
.save-button, .delete-button {
color: #fff;
padding: 8px 16px;
border-radius: 4px;
border: none;
font-weight: 500;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
}
.save-button > svg, .delete-button > svg {
width: 20px;
margin: 0 5px 2.5px 0;
}
.confirm-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1100;
}
.confirm-content {
padding: 24px;
background-color: var(--background-color);
border-radius: 8px;
max-width: 400px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.confirm-buttons {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 20px;
}
.confirm-buttons button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
color: var(--text-color);
background-color: var(--divider-color);
}
</style>

296
src/components/EpubView.vue Normal file
View file

@ -0,0 +1,296 @@
<template>
<div class="reader">
<div class="viewHolder">
<div ref="viewer" id="viewer" v-show="isLoaded"></div>
<div v-if="!isLoaded">
<slot name="loadingView"> </slot>
</div>
</div>
<button
class="arrow pre"
@click="prevPage"
:disabled="location?.atStart"
>
</button>
<button
class="arrow next"
@click="nextPage"
:disabled="location?.atEnd"
>
</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRefs, watch, unref } from 'vue'
import ePub from 'epubjs'
import type { Book, Rendition, Contents } from 'epubjs'
import {
clickListener,
swipListener,
wheelListener,
keyListener,
} from '../utils/listeners/listener'
interface Props {
url: string | ArrayBuffer
location?: any // Current Page number | string | Rendition['location']['start']
tocChanged?: (toc: Book['navigation']['toc']) => void
getRendition?: (rendition: Rendition) => void
handleTextSelected?: (cfiRange: string, contents: Contents) => void
handleKeyPress?: () => void
epubInitOptions?: Book['settings']
epubOptions?: Rendition['settings']
}
const props = withDefaults(defineProps<Props>(), {
epubInitOptions: () => ({}),
epubOptions: () => ({}),
})
const {
tocChanged,
getRendition,
handleTextSelected,
handleKeyPress,
epubInitOptions,
epubOptions,
} = props
const { url, location } = toRefs(props)
const emit = defineEmits<{
(e: 'update:location', location: Props['location']): void
}>()
const viewer = ref<HTMLDivElement | null>(null)
const toc = ref<Book['navigation']['toc']>([])
const isLoaded = ref(false)
let book: null | Book = null,
rendition: null | Rendition = null
const initBook = async () => {
if (book) book.destroy()
if (url.value) {
try {
book = ePub(unref(url.value), epubInitOptions)
book!.ready.then(() => {
return book!.loaded.navigation
}).then(({ toc: _toc }) => {
isLoaded.value = true
toc.value = _toc
tocChanged && tocChanged(_toc)
initReader()
}).catch(error => {
console.error('Error loading book navigation:', error)
// try to continue without navigation
isLoaded.value = true
initReader()
})
} catch (error) {
console.error('Error initializing book:', error)
}
}
}
const initReader = () => {
if (!book) return
try {
rendition = book!.renderTo(viewer.value as HTMLDivElement, {
width: '100%',
height: '100%',
...epubOptions,
})
if (rendition && book) {
const spine_get = book.spine.get.bind(book.spine)
book.spine.get = function(target: any) {
let t = spine_get(target)
// Handle relative paths
if (!t && target && typeof target === 'string' && target.startsWith('../')) {
target = target.substring(3)
t = spine_get(target)
}
// Try to find by href match
if (!t && typeof target === 'string') {
let i = 0;
let spineItem = book!.spine.get(i);
// Iterate through spine items until we find a match or reach the end
while (spineItem) {
if (spineItem.href === target ||
spineItem.href.endsWith(target)) {
return spineItem;
}
i++;
spineItem = book!.spine.get(i);
}
}
return t
}
}
registerEvents()
getRendition && getRendition(rendition)
if (typeof location?.value === 'string') {
rendition.display(location.value).catch(err => {
console.error('Error displaying location:', err)
displayFallback()
})
} else if (typeof location?.value === 'number') {
rendition.display(location.value).catch(err => {
console.error('Error displaying page number:', err)
displayFallback()
})
} else if (toc.value.length > 0 && toc?.value[0]?.href) {
rendition.display(toc.value[0].href).catch(err => {
console.error('Error displaying TOC:', err)
displayFallback()
})
} else {
displayFallback()
}
} catch (error) {
console.error('Error initializing reader:', error)
}
}
const displayFallback = () => {
if (!rendition) return
// Try to display with empty parameter
rendition.display().catch(err => {
console.error('Error with default display:', err)
try {
// Use the first() method from the Spine class to get the first section
const firstSection = book?.spine?.first()
if (firstSection && firstSection.href) {
rendition!.display(firstSection.href).catch(finalErr => {
console.error('Final display error:', finalErr)
})
}
} catch (error) {
console.error('Error accessing spine:', error)
}
})
}
const flipPage = (direction: string) => {
if (direction === 'next') nextPage()
else if (direction === 'prev') prevPage()
}
const registerEvents = () => {
if (rendition) {
rendition.on('rendered', (e: Event, iframe: any) => {
iframe?.iframe?.contentWindow.focus()
// clickListener(iframe?.document, rendition as Rendition, flipPage);
// selectListener(iframe.document, rendition, toggleBuble);
if (!epubOptions?.flow?.includes('scrolled'))
wheelListener(iframe.document, flipPage)
swipListener(iframe.document, flipPage)
keyListener(iframe.document, flipPage)
})
rendition.on('locationChanged', onLocationChange)
rendition.on('displayError', (err: any) => {
console.error('Display error:', err)
})
if (handleTextSelected) {
rendition.on('selected', handleTextSelected)
}
if (handleKeyPress) {
rendition.on('selected', handleKeyPress)
}
}
}
const onLocationChange = (loc: Rendition['location']) => {
const newLocation = loc && loc.start
if (location?.value !== newLocation) {
emit('update:location', newLocation)
}
}
watch(url, () => {
initBook()
})
const nextPage = () => {
rendition?.next()
}
const prevPage = () => {
rendition?.prev()
}
const setLocation = (href: number | string) => {
if (typeof href === 'string') rendition!.display(href)
if (typeof href === 'number') rendition!.display(href)
}
onMounted(() => {
initBook()
})
onUnmounted(() => {
book?.destroy()
})
defineExpose({
nextPage,
prevPage,
setLocation,
})
</script>
<style scoped>
.reader {
position: absolute;
inset: 50px 10px 10px 10px;
}
.viewHolder {
height: 100%;
width: 100%;
position: relative;
}
#viewer {
height: 100%;
}
.arrow {
width: 30%;
outline: none;
border: none;
background: none;
position: absolute;
bottom: -10px;
margin-top: -32px;
font-size: 3rem;
color: var(--accent-color);
font-family: arial, sans-serif;
cursor: pointer;
user-select: none;
appearance: none;
font-weight: bold;
}
.arrow:hover {
color: #777;
}
.arrow:disabled {
cursor: not-allowed;
color: #e2e2e2;
}
.prev {
left: 1px;
}
.next {
right: 1px;
}
</style>

View file

@ -0,0 +1,99 @@
<!-- FontSelector.vue -->
<template>
<div class="font-selector">
<label :for="id">{{ label }}</label>
<select
:id="id"
:value="modelValue"
@change="handleChange"
class="font-select"
>
<option
v-for="font in fontOptions"
:key="font.value"
:value="font.value"
:style="{ fontFamily: font.value }"
>
{{ font.label }}
</option>
</select>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue';
import { type FontOption } from '../types/styles';
export default defineComponent({
name: 'FontSelector',
props: {
label: {
type: String,
default: 'Font Family'
},
id: {
type: String,
default: 'font-selector'
},
modelValue: {
type: String,
required: true
},
fonts: {
type: Array as PropType<FontOption[]>,
default: () => []
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
// Default font options if none provided
const defaultFonts: FontOption[] = [
{ label: 'Arial', value: 'Arial, sans-serif' },
{ label: 'Times New Roman', value: 'Times New Roman, serif' },
{ label: 'Georgia', value: 'Georgia, serif' },
{ label: 'Verdana', value: 'Verdana, sans-serif' },
{ label: 'Courier New', value: 'Courier New, monospace' }
];
const fontOptions = props.fonts.length > 0 ? props.fonts : defaultFonts;
const handleChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
emit('update:modelValue', target.value);
};
return {
fontOptions,
handleChange
};
}
});
</script>
<style scoped>
.font-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-size: 0.9rem;
color: var(--text-color, #000000);
}
.font-select {
padding: 0.5rem;
border-radius: 4px;
border: 1px solid var(--divider-color);
background-color: var(--background-color, #ffffff);
color: var(--text-color, #000000);
width: 100%;
cursor: pointer;
}
.font-select:focus {
outline: 2px solid var(--accent-color, #f5a623);
border-color: transparent;
}
</style>

View file

@ -0,0 +1,183 @@
<!-- FontSizeSelector.vue -->
<template>
<div class="font-size-selector">
<label :for="id">{{ label }}</label>
<div class="font-size-controls">
<button
@click="decrease"
:disabled="currentSize <= minSize"
class="size-button"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="currentColor" d="M19 13H5v-2h14v2z"/>
</svg>
</button>
<input
type="number"
v-model.number="currentSize"
@input="handleInputChange"
:min="minSize"
:max="maxSize"
class="size-input"
/>
<button
@click="increase"
:disabled="currentSize >= maxSize"
class="size-button"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch } from 'vue';
export default defineComponent({
name: 'FontSizeSelector',
props: {
label: {
type: String,
default: 'Font Size'
},
id: {
type: String,
default: 'font-size-selector'
},
modelValue: {
type: String,
required: true
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const minSize = 10;
const maxSize = 24;
const baseSize = 16; // Base font size in points
// Convert percentage to points
const percentageToPoints = (percentage: string): number => {
const percent = parseFloat(percentage);
return Math.round((percent / 100) * baseSize);
};
// Convert points to percentage
const pointsToPercentage = (points: number): string => {
return `${Math.round((points / baseSize) * 100)}%`;
};
// Current size in points
const currentSize = ref(percentageToPoints(props.modelValue));
// Watch for external changes
watch(() => props.modelValue, (newValue) => {
currentSize.value = percentageToPoints(newValue);
});
const handleInputChange = () => {
// Ensure value is within bounds
if (currentSize.value < minSize) currentSize.value = minSize;
if (currentSize.value > maxSize) currentSize.value = maxSize;
const newPercentage = pointsToPercentage(currentSize.value);
emit('update:modelValue', newPercentage);
};
const increase = () => {
if (currentSize.value < maxSize) {
currentSize.value++;
const newPercentage = pointsToPercentage(currentSize.value);
emit('update:modelValue', newPercentage);
}
};
const decrease = () => {
if (currentSize.value > minSize) {
currentSize.value--;
const newPercentage = pointsToPercentage(currentSize.value);
emit('update:modelValue', newPercentage);
}
};
return {
currentSize,
minSize,
maxSize,
handleInputChange,
increase,
decrease
};
}
});
</script>
<style scoped>
.font-size-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-size: 0.9rem;
color: var(--text-color, #000000);
}
.font-size-controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5rem;
}
.size-button {
border: 1px solid var(--divider-color);
border-radius: 4px;
background-color: var(--background-color, #ffffff);
color: var(--text-color, #000000);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.size-button:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.05);
}
.size-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.size-input {
padding: 0.5rem;
border: 1px solid var(--divider-color);
border-radius: 4px;
background-color: var(--background-color, #ffffff);
color: var(--text-color, #000000);
text-align: center;
font-size: 0.9rem;
}
.size-input:focus {
outline: 2px solid var(--accent-color, #f5a623);
border-color: transparent;
}
/* Hide number input arrows */
.size-input::-webkit-inner-spin-button,
.size-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.size-input[type=number] {
-moz-appearance: textfield;
}
</style>

View file

@ -0,0 +1,96 @@
<template>
<div class="language-selector">
<select
:value="currentLocale"
@change="handleLocaleChange"
class="locale-select"
>
<option
v-for="locale in availableLocales"
:key="locale"
:value="locale"
>
{{ getLanguageName(locale) }}
</option>
</select>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { useI18n } from '../i18n/usei18n';
export default defineComponent({
name: 'LanguageSelector',
setup() {
const { currentLocale, availableLocales, setLocale } = useI18n();
const languageNames: Record<string, string> = {
en: 'EN',
cs: 'CZ',
sk: 'SK',
es: 'Español',
fr: 'Français',
de: 'Deutsch',
it: 'Italiano',
pt: 'Português',
ru: 'Русский',
zh: '中文',
ja: '日本語',
ko: '한국어'
};
const getLanguageName = (locale: string): string => {
return languageNames[locale] || locale;
};
const handleLocaleChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
setLocale(target.value);
};
return {
currentLocale,
availableLocales,
getLanguageName,
handleLocaleChange
};
}
});
</script>
<style scoped>
.language-selector {
display: inline-block;
}
.locale-select {
padding: 0.5rem;
background-color: var(--background-color);
border: 1px solid var(--accent-color);
border-radius: 0.375rem;
font-size: 0.875rem;
color: var(--text-color);
cursor: pointer;
}
/* Remove dropdown arrow */
select {
appearance: none;
/* for Firefox */
-moz-appearance: none;
/* for Chrome */
-webkit-appearance: none;
}
/* For IE10 */
select::-ms-expand {
display: none;
}
.locale-select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
</style>

View file

@ -0,0 +1,87 @@
<!-- RgbColorPicker.vue -->
<template>
<div class="color-picker">
<label>{{ props.label }}</label>
<div class="color-input-group">
<input
type="color"
:value="props.modelValue"
@input="(e) => updateHexColor((e.target as HTMLInputElement).value)"
/>
<input
type="text"
:value="props.modelValue"
@input="(e) => updateHexColor((e.target as HTMLInputElement).value)"
/>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string;
modelValue: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
// Update hex color from input
const updateHexColor = (hexValue: string): void => {
// Make sure the hex has a # prefix
if (!hexValue.startsWith('#')) {
hexValue = '#' + hexValue;
}
// Validate the hex format
if (/^#[0-9A-F]{6}$/i.test(hexValue)) {
emit('update:modelValue', hexValue);
}
};
</script>
<script lang="ts">
export default {
name: 'RgbColorPicker'
};
</script>
<style scoped>
.color-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.color-picker label {
font-weight: 500;
margin-bottom: 0.25rem;
color: var(--text-color, #000000);
}
.color-input-group {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.color-input-group input[type="color"] {
width: 40px;
height: 40px;
padding: 0;
border: 1px solid var(--divider-color);
border-radius: 4px;
cursor: pointer;
}
.color-input-group input[type="text"] {
flex: 1;
width: 100%;
height: 40px;
padding: 0 0.5rem;
border: 1px solid var(--divider-color);
border-radius: 4px;
}
</style>

View file

@ -0,0 +1,82 @@
<template>
<button class="styles-button" :class="{stylesButtonActive: isOpen}" @click="toggleStylesModal">
<div class="styles-button-bar-1"></div>
<div class="styles-button-bar-2"></div>
<div class="styles-button-bar-3"></div>
<div class="styles-button-bar-4"></div>
</button>
</template>
<script>
export default {
name: 'StylesButton',
props: {
isOpen: {
type: Boolean,
default: false
}
},
methods: {
toggleStylesModal() {
this.$emit('toggle')
}
}
}
</script>
<style scoped>
.styles-button {
color: var(--accent-color);
transition: transform ease-in 0.3s;
background: none;
border: none;
border-radius: 2px;
cursor: pointer;
height: 32px;
width: 32px;
outline: none;
position: absolute;
top: 6px;
right: 6px;
}
.styles-button > div {
position: absolute;
left: 20%;
width: 60%;
height: 0;
border-width: 2px 0 0 0;
border-color: var(--accent-color);
transition: all 0.3s ease;
}
.styles-button .styles-button-bar-1 {
top: 30%;
border-style: solid;
}
.styles-button .styles-button-bar-2 {
top: 42.5%;
border-style: dashed;
}
.styles-button .styles-button-bar-3 {
top: 57.5%;
border-style: solid;
}
.styles-button .styles-button-bar-4 {
top: 70%;
border-style: dotted;
}
.stylesButtonActive .styles-button-bar-1 {
top: 50%;
transform: rotate(45deg);
}
.stylesButtonActive .styles-button-bar-2,
.stylesButtonActive .styles-button-bar-4 {
opacity: 0;
}
.stylesButtonActive .styles-button-bar-3 {
top: 50%;
transform: rotate(-45deg);
}
</style>

View file

@ -0,0 +1,375 @@
<!-- StylesModal.vue -->
<template>
<div class="styles-modal" :class="{ 'open': isOpen }">
<div class="styles-modal-content">
<div class="styles-modal-header">
<div class="modal-title-area">
<h4 v-if="title">{{ title }}</h4>
</div>
</div>
<div class="styles-modal-body">
<div class="preset-colors">
<h4>{{ t('settings.presets') }}</h4>
<div class="preset-buttons">
<button
v-for="preset in presets"
:key="preset.name"
@click="applyPreset(preset.value)"
class="preset-button"
:style="{
backgroundColor: preset.bgColor,
color: preset.textColor,
}"
>
<span>{{ preset.name }}</span>
</button>
</div>
</div>
<font-selector
:label="t('settings.fontFamily')"
:model-value="fontFamilyValue"
@update:model-value="updateFontFamily"
:fonts="fontOptions"
/>
<font-size-selector
:label="t('settings.fontSize')"
:model-value="fontSizeValue"
@update:model-value="updateFontSize"
/>
<rgb-color-picker
:label="t('settings.textColor')"
:model-value="textColorValue"
@update:model-value="updateTextColor"
/>
<rgb-color-picker
:label="t('settings.backgroundColor')"
:model-value="backgroundColorValue"
@update:model-value="updateBackgroundColor"
/>
<rgb-color-picker
:label="t('settings.accentColor')"
:model-value="accentColorValue"
@update:model-value="updateAccentColor"
/>
<LanguageSelector class="lang-selector" />
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, toRef, computed, watch } from 'vue';
import RgbColorPicker from './RgbColorPicker.vue';
import FontSelector from './FontSelector.vue';
import FontSizeSelector from './FontSizeSelector.vue';
import LanguageSelector from '../components/LanguageSelector.vue';
import { useI18n } from '../i18n/usei18n';
import { type RenditionTheme, type PresetOption, type FontOption } from '../types/styles';
export default defineComponent({
name: 'StylesModal',
components: {
RgbColorPicker,
FontSelector,
FontSizeSelector,
LanguageSelector
},
props: {
isOpen: {
type: Boolean,
default: false
},
textColor: {
type: String,
default: '#000000'
},
backgroundColor: {
type: String,
default: '#ffffff'
},
accentColor: {
type: String,
default: '#f5a623'
},
fontFamily: {
type: String,
default: 'Arial, sans-serif'
},
fontSize: {
type: String,
default: '100%'
},
rendition: {
type: Object as PropType<RenditionTheme | null>,
default: null
},
title: {
type: String,
default: ''
},
presetsTitle: {
type: String,
default: ''
},
customPresets: {
type: Array as PropType<PresetOption[]>,
default: null
},
customFonts: {
type: Array as PropType<FontOption[]>,
default: null
}
},
emits: [
'close',
'update:textColor',
'update:backgroundColor',
'update:accentColor',
'update:fontFamily',
'update:fontSize',
'preset-applied'
],
setup(props, { emit }) {
const { t } = useI18n();
// Create refs from props for template access
const textColorValue = toRef(props, 'textColor');
const backgroundColorValue = toRef(props, 'backgroundColor');
const accentColorValue = toRef(props, 'accentColor');
const fontFamilyValue = toRef(props, 'fontFamily');
const fontSizeValue = toRef(props, 'fontSize');
// Update functions that emit events to parent for v-model binding
const updateTextColor = (value: string): void => {
emit('update:textColor', value);
};
const updateBackgroundColor = (value: string): void => {
emit('update:backgroundColor', value);
};
const updateAccentColor = (value: string): void => {
emit('update:accentColor', value);
};
const updateFontFamily = (value: string): void => {
emit('update:fontFamily', value);
};
const updateFontSize = (value: string): void => {
emit('update:fontSize', value);
};
// Default presets if custom presets not provided
const defaultPresets = [
{
name: t('settings.white'),
value: 'light',
bgColor: '#ffffff',
textColor: '#000000'
},
{
name: t('settings.black'),
value: 'dark',
bgColor: '#121212',
textColor: '#ffffff'
},
{
name: t('settings.sepia'),
value: 'sepia',
bgColor: '#f4ecd8',
textColor: '#5b4636'
}
];
// Default font options if none provided
const defaultFontOptions: FontOption[] = [
{ label: 'Arial', value: 'Arial, sans-serif' },
{ label: 'Times New Roman', value: 'Times New Roman, serif' },
{ label: 'Georgia', value: 'Georgia, serif' },
{ label: 'Verdana', value: 'Verdana, sans-serif' },
{ label: 'Courier New', value: 'Courier New, monospace' },
{ label: 'Fast Sans', value: 'Fast Sans, sans-serif' },
{ label: 'Fast Serif', value: 'Fast Serif, serif' },
{ label: 'Fast Mono', value: 'Fast Mono, monospace' },
{ label: 'Fast Dotted', value: 'Fast Dotted, sans-serif' },
];
// Use custom presets if provided, else use defaults
const presets = computed(() => props.customPresets || defaultPresets);
// Use custom fonts if provided, else use defaults
const fontOptions = computed(() => props.customFonts || defaultFontOptions);
// Function to apply preset color schemes
const applyPreset = (preset: string): void => {
let newTextColor: string, newBackgroundColor: string, newAccentColor: string;
switch(preset) {
case 'light':
newTextColor = '#000000';
newBackgroundColor = '#ffffff';
newAccentColor = '#f5a623';
break;
case 'dark':
newTextColor = '#ffffff';
newBackgroundColor = '#121212';
newAccentColor = '#bb86fc';
break;
case 'sepia':
newTextColor = '#5b4636';
newBackgroundColor = '#f4ecd8';
newAccentColor = '#d9813b';
break;
default:
// For custom presets, find matching preset by value
const customPreset = presets.value.find(p => p.value === preset);
if (customPreset) {
// This is a simplified example. In real-world usage, you'd need to define
// specific values for all colors in each custom preset
newTextColor = customPreset.textColor;
newBackgroundColor = customPreset.bgColor;
newAccentColor = props.accentColor; // Keep current accent color as fallback
} else {
return; // Unknown preset, do nothing
}
}
// Emit updated values
emit('update:textColor', newTextColor);
emit('update:backgroundColor', newBackgroundColor);
emit('update:accentColor', newAccentColor);
// Emit preset-applied event with preset name
emit('preset-applied', preset);
};
// Add event listener to handle escape key
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
} else {
document.removeEventListener('keydown', handleKeyDown);
}
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
close()
}
};
// Close modal
const close = (): void => {
emit('close');
};
return {
t,
textColorValue,
backgroundColorValue,
accentColorValue,
fontFamilyValue,
fontSizeValue,
updateTextColor,
updateBackgroundColor,
updateAccentColor,
updateFontFamily,
updateFontSize,
presets,
fontOptions,
applyPreset,
close
};
}
});
</script>
<style scoped>
.styles-modal {
position: fixed;
top: 0;
right: 0;
transform: translate(256px);
width: 256px;
height: 100vh;
background-color: var(--background-color, #ffffff);
border-left: 1px solid var(--divider-color);
z-index: 1000;
transition: all 0.3s ease-in-out;
overflow-y: auto;
}
.styles-modal.open {
transform: translateX(0px);
}
.styles-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 5px;
}
.modal-title-area {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.styles-modal-header h4 {
margin: 0;
color: var(--text-color, #000000);
font-weight: bold;
padding: 0 0 0 0.5rem;
}
.close-button {
background: none;
border: none;
color: var(--accent-color);
cursor: pointer;
padding: 0.5rem;
}
.styles-modal-body {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem 2rem;
}
.preset-colors {
margin-bottom: 1rem;
}
.preset-colors h4 {
margin-top: 0;
margin-bottom: 0.5rem;
color: var(--text-color, #000000);
}
.preset-buttons {
display: flex;
gap: 0.5rem;
}
.preset-button {
font-family: var(--font-family);
flex: 1;
padding: 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: transform 0.2s;
border: 1px solid var(--divider-color);
}
.preset-button:hover {
transform: translateY(-2px);
}
</style>

View file

@ -0,0 +1,279 @@
// src/composables/useStyles.ts
import { ref, watch, nextTick } from 'vue';
import { type RenditionTheme, type StylesOptions } from '../types/styles';
export function useStyles(options: StylesOptions = {}) {
// Initialize style refs with defaults or provided values
const textColor = ref(options.initialTextColor || '#000000');
const backgroundColor = ref(options.initialBackgroundColor || '#ffffff');
const accentColor = ref(options.initialAccentColor || '#f5a623');
const fontFamily = ref(options.initialFontFamily || 'Arial, sans-serif');
const fontSize = ref(options.initialFontSize || '100%');
const stylesModalOpen = ref(false);
const rendition = ref<RenditionTheme | null>(null);
const baseUrl = import.meta.env.VITE_BASE_URL;
const customFonts = {
'Fast Sans': `${baseUrl}fonts/Fast_Sans.ttf`,
'Fast Serif': `${baseUrl}fonts/Fast_Serif.ttf`,
'Fast Mono': `${baseUrl}fonts/Fast_Mono.ttf`,
'Fast Dotted': `${baseUrl}fonts/Fast_Sans_Dotted.ttf`
};
// Track if hooks are registered to avoid duplicate registration
let hooksRegistered = false;
// Local storage management
const loadSavedStyles = () => {
const savedStyles = {
text: localStorage.getItem('reader-text-color'),
background: localStorage.getItem('reader-background-color'),
accent: localStorage.getItem('accent-color'),
fontFamily: localStorage.getItem('reader-font-family'),
fontSize: localStorage.getItem('reader-font-size')
};
if (savedStyles.text) textColor.value = savedStyles.text;
if (savedStyles.background) backgroundColor.value = savedStyles.background;
if (savedStyles.accent) accentColor.value = savedStyles.accent;
if (savedStyles.fontFamily) fontFamily.value = savedStyles.fontFamily;
if (savedStyles.fontSize) fontSize.value = savedStyles.fontSize;
applyStylesToDocument();
};
const saveStyles = () => {
localStorage.setItem('reader-text-color', textColor.value);
localStorage.setItem('reader-background-color', backgroundColor.value);
localStorage.setItem('accent-color', accentColor.value);
localStorage.setItem('reader-font-family', fontFamily.value);
localStorage.setItem('reader-font-size', fontSize.value);
};
const applyStylesToDocument = () => {
const root = document.documentElement;
root.style.setProperty('--text-color', textColor.value);
root.style.setProperty('--background-color', backgroundColor.value);
root.style.setProperty('--accent-color', accentColor.value);
root.style.setProperty('--font-family', fontFamily.value);
root.style.setProperty('--font-size', fontSize.value);
setMeta('apple-mobile-web-app-status-bar-style', 'black-translucent');
setMeta('theme-color', backgroundColor.value);
};
// Create CSS with font-face declarations for epub.js
const createFontFaceCSS = (): string => {
let fontCSS = '';
for (const [fontName, fontUrl] of Object.entries(customFonts)) {
fontCSS += `
@font-face {
font-family: '${fontName}';
src: url('${fontUrl}') format('truetype');
font-display: swap;
}
`;
}
return fontCSS;
};
// Apply styles to a specific content document
const applyStylesToContent = (doc: Document) => {
const head = doc.head || doc.getElementsByTagName('head')[0];
if (!head) return;
// Remove existing custom styles to avoid duplicates
const existingStyles = head.querySelectorAll('[data-custom-styles]');
existingStyles.forEach(style => style.remove());
// Create and inject font styles
const fontStyle = doc.createElement('style');
fontStyle.setAttribute('data-custom-styles', 'fonts');
fontStyle.textContent = createFontFaceCSS();
head.appendChild(fontStyle);
// Create and inject theme styles
const themeStyle = doc.createElement('style');
themeStyle.setAttribute('data-custom-styles', 'theme');
themeStyle.textContent = `
body, html {
color: ${textColor.value} !important;
background-color: ${backgroundColor.value} !important;
font-family: ${fontFamily.value} !important;
font-size: ${fontSize.value} !important;
transition: all 0.3s ease;
}
* {
color: inherit !important;
font-family: inherit !important;
}
p, div, span, h1, h2, h3, h4, h5, h6 {
color: ${textColor.value} !important;
font-family: ${fontFamily.value} !important;
}
`;
head.appendChild(themeStyle);
};
// Apply styles to all currently loaded content
const applyStylesToAllContent = () => {
if (!rendition.value) return;
try {
// Get all iframes (epub.js uses iframes for content)
const iframes = rendition.value.manager?.container?.querySelectorAll('iframe');
if (iframes) {
iframes.forEach((iframe: HTMLIFrameElement) => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
applyStylesToContent(doc);
}
} catch (error) {
console.warn('Could not access iframe content:', error);
}
});
}
// Also try to get content through epub.js API
if (rendition.value.getContents) {
const contents = rendition.value.getContents();
contents.forEach((content: any) => {
if (content.document) {
applyStylesToContent(content.document);
}
});
}
} catch (error) {
console.error('Error applying styles to all content:', error);
}
};
const registerContentHooks = () => {
if (!rendition.value || hooksRegistered) return;
try {
rendition.value.hooks.content.register((contents: any) => {
if (contents.document) {
applyStylesToContent(contents.document);
}
});
hooksRegistered = true;
} catch (error) {
console.error('Error registering content hooks:', error);
}
};
const applyStylesToReader = async () => {
if (!rendition.value) return;
try {
if (rendition.value.themes) {
const { themes } = rendition.value;
themes.override('color', textColor.value);
themes.override('background', backgroundColor.value);
themes.override('font-family', fontFamily.value);
if (themes.fontSize) {
themes.fontSize(fontSize.value);
} else {
themes.override('font-size', fontSize.value);
}
}
await nextTick();
applyStylesToAllContent();
registerContentHooks();
} catch (error) {
console.error('Error applying styles to reader:', error);
}
};
// Update status bar meta tags
const setMeta = (name: string, content: string) => {
let tag = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement || document.createElement('meta');
tag.setAttribute('name', name);
tag.setAttribute('content', content);
if (!tag.parentNode) document.head.appendChild(tag);
};
// Rendition setup (enhanced)
const setRendition = async (renditionObj: RenditionTheme): Promise<void> => {
rendition.value = renditionObj;
hooksRegistered = false; // Reset hook registration flag
// Wait for rendition to be ready
if (renditionObj.display) {
await renditionObj.display();
}
// Apply styles after display
await applyStylesToReader();
};
const toggleStylesModal = () => {
stylesModalOpen.value = !stylesModalOpen.value;
};
// Force refresh all styles (useful for debugging or manual refresh)
const refreshStyles = async () => {
await nextTick();
applyStylesToDocument();
await applyStylesToReader();
};
// Watch for style changes with debouncing
let styleUpdateTimeout: NodeJS.Timeout | null = null;
watch(
[textColor, backgroundColor, accentColor, fontFamily, fontSize],
async () => {
// Clear existing timeout
if (styleUpdateTimeout) {
clearTimeout(styleUpdateTimeout);
}
// Debounce style updates to avoid too frequent changes
styleUpdateTimeout = setTimeout(async () => {
applyStylesToDocument();
await applyStylesToReader();
saveStyles();
if (options.onStyleChange) {
options.onStyleChange(
textColor.value,
backgroundColor.value,
accentColor.value,
fontFamily.value,
fontSize.value
);
}
}, 100);
}
);
loadSavedStyles();
return {
textColor,
backgroundColor,
accentColor,
fontFamily,
fontSize,
stylesModalOpen,
rendition,
toggleStylesModal,
setRendition,
applyStylesToReader,
applyStylesToDocument,
refreshStyles
};
}

9
src/i18n/index.ts Normal file
View file

@ -0,0 +1,9 @@
import { createI18n } from './usei18n';
import { translations } from './translations';
export const i18n = createI18n({
fallbackLocale: 'sk',
messages: translations
});

91
src/i18n/translations.ts Normal file
View file

@ -0,0 +1,91 @@
// src/i18n/translations.ts
export const translations = {
en: {
library: {
library: 'Library',
title: 'Title',
emptyLibrary: 'No books found in the library.',
loading: 'Loading your library...',
download: 'Download',
read: 'Read',
size: 'Size',
local: 'Owned',
added: 'Added',
filename: 'File name',
},
reader: {
loading: 'Loading',
back: 'Back',
},
settings: {
settings: 'Settings',
textColor: 'Text Color',
backgroundColor: 'Background Color',
accentColor: 'Accent Color',
fontFamily: 'Font',
fontSize: "Font Size",
presets: 'Presets',
white: 'Light',
black: 'Dark',
sepia: 'Sepia',
bookSettings: 'Book Settings',
saveChanges: 'Save Changes',
deleteBook: 'Delete Book',
confirmDelete: 'Are you sure you want to delete this book? This action is irreversible!',
cancel: 'Cancel',
delete: 'Delete'
},
messages: {
success: 'Operation completed successfully',
error: 'An error occurred',
loading: 'Loading...',
confirmDelete: 'Are you sure you want to delete ',
noResults: 'No results found',
welcome: 'Welcome!'
},
},
sk: {
library: {
library: 'Knižnica',
title: 'Názov',
emptyLibrary: 'V knižnici sa nenašli žiadne knihy.',
loading: 'Načítavam Vašu knižnicu...',
download: 'Stiahnuť',
read: 'Čítať',
size: 'Veľkosť',
local: 'Moje',
added: 'Pridané',
filename: 'Názov súboru',
},
reader: {
loading: 'Načítavam',
back: 'Späť',
},
settings: {
settings: 'Nastavenia',
textColor: 'Farba Textu',
backgroundColor: 'Farba Pozadia',
accentColor: 'Terciárna Farba',
fontFamily: 'Písmo',
fontSize: "Veľkosť písma",
presets: 'Predvoľby',
white: 'Svetlá',
black: 'Tmavá',
sepia: 'Sépia',
bookSettings: 'Detail Knihy',
saveChanges: 'Uložiť zmeny',
deleteBook: 'Odstrániť Knihu',
confirmDelete: 'Naozaj chcete odstrániť ',
cancel: 'Zrušiť',
delete: 'Odstrániť'
},
messages: {
success: 'Operácia bola úspešne dokončená',
error: 'Došlo k chybe',
loading: 'Načítava sa...',
confirmDelete: 'Naozaj chcete zmazať túto položku?',
noResults: 'Nenašli sa žiadne výsledky',
welcome: 'Vitajte!'
},
},
};

91
src/i18n/usei18n.ts Normal file
View file

@ -0,0 +1,91 @@
// i18n/useI18n.ts
import { ref, computed, inject, type InjectionKey } from 'vue';
import { type TranslationMessages, type I18nConfig } from '../types/i18n';
export const I18nKey: InjectionKey<ReturnType<typeof createI18n>> = Symbol('i18n');
export function createI18n(config: I18nConfig) {
const defaultLocale = config.fallbackLocale;
const detectUserLocale = (): string => {
// 1. Check local storage first
const storedLocale = localStorage.getItem('user-locale');
if (storedLocale && config.messages[storedLocale]) {
return storedLocale;
}
// Detect user's preferred locale as browser language
const browserLang = navigator.language.split('-')[0];
if (browserLang && config.messages[browserLang]) {
return browserLang;
}
// 3. Fall back to default
return defaultLocale;
};
const currentLocale = ref(detectUserLocale());
const messages = ref(config.messages);
const setLocale = (locale: string) => {
if (messages.value[locale]) {
currentLocale.value = locale;
localStorage.setItem('user-locale', locale);
}
};
const t = (key: string, params?: Record<string, string | number>): string => {
const keys = key.split('.');
// Try to find translation in current locale
let result = findTranslation(keys, currentLocale.value);
// If not found, try fallback locale
if (!result) {
result = findTranslation(keys, defaultLocale);
}
// Return original key if translation not found
if (!result) return key;
// Process any parameters if needed
if (params) {
return result.replace(/\{(\w+)\}/g, (match, paramKey) => {
return params[paramKey]?.toString() || match;
});
}
return result;
};
// Helper function to find translation
const findTranslation = (keys: string[], locale: string): string | null => {
let current: any = messages.value[locale];
for (const k of keys) {
if (current && typeof current === 'object' && k in current) {
current = current[k];
} else {
return null;
}
}
return typeof current === 'string' ? current : null;
};
const availableLocales = computed(() => Object.keys(messages.value));
return {
currentLocale,
setLocale,
t,
availableLocales,
messages
};
}
export function useI18n() {
const i18n = inject(I18nKey);
if (!i18n) {
throw new Error('useI18n() must be used inside a component with i18n provided');
}
return i18n;
}

21
src/main.ts Executable file
View file

@ -0,0 +1,21 @@
import './assets/main.css'
import { createApp } from 'vue'
import { I18nKey } from './i18n/usei18n';
import { i18n } from './i18n';
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
// Provide i18n to all components
app.provide(I18nKey, i18n);
app.mount('#app')
// main.ts

24
src/router/index.ts Executable file
View file

@ -0,0 +1,24 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import LibraryView from '../views/LibraryView.vue';
import ReaderView from '../views/ReaderView.vue';
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'Library',
component: LibraryView,
},
{
path: '/reader/:bookId',
name: 'Reader',
component: ReaderView,
props: true
}
],
})
export default router

View file

@ -0,0 +1,190 @@
// src/services/importBookService.ts
import { type EpubFile } from '../types/epubFile';
import { initializeDB, DB_NAME, STORE_NAME } from '../utils/utils';
export class ImportBookService {
/**
* Load books from IndexedDB
*/
async loadLocalBooks(): Promise<EpubFile[]> {
try {
const db = await initializeDB();
return new Promise<EpubFile[]>((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
const localBooks = request.result.map((book: EpubFile) => ({
...book,
isLocal: true
}));
resolve(localBooks);
db.close();
};
request.onerror = (event: Event) => {
console.error('Error loading local books:', (event.target as IDBRequest).error);
reject(new Error('Failed to load your local books.'));
db.close();
};
});
} catch (err) {
console.error('Error accessing IndexedDB:', err);
return [];
}
}
/**
* Store a book in IndexedDB
*/
async storeBook(book: EpubFile, blob?: Blob): Promise<string> {
try {
const db = await initializeDB();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
// Ensure book has a proper local ID
let bookId = book.id;
if (!bookId.startsWith('local-') && !bookId.startsWith('import-')) {
bookId = `local-${book.originalId || book.id}`;
}
// Prepare the book data for storage
const bookData: EpubFile = {
...book,
id: bookId,
originalId: book.originalId || (book.isRemote ? book.id.replace('remote-', '') : book.id),
isLocal: true,
data: blob || book.data,
dateAdded: book.dateAdded || new Date().getTime()
};
return new Promise<string>((resolve, reject) => {
const request = store.put(bookData);
request.onsuccess = () => {
resolve(bookId);
};
request.onerror = (event: Event) => {
console.error('Error storing book in IndexedDB:', (event.target as IDBRequest).error);
reject(new Error('Failed to save the book locally. Please try again.'));
};
transaction.oncomplete = () => {
db.close();
};
});
} catch (err) {
console.error('Error storing book:', err);
throw err;
}
}
/**
* Check if a book exists in IndexedDB
*/
async bookExistsInDB(book: EpubFile): Promise<boolean> {
try {
const db = await initializeDB();
// Try to find by different ID formats
const possibleIds = [
book.id,
`local-${book.originalId || book.id.replace('remote-', '')}`,
`remote-${book.originalId || book.id}`
];
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
for (const id of possibleIds) {
const exists = await new Promise<boolean>((resolve) => {
const request = store.get(id);
request.onsuccess = () => {
resolve(!!request.result);
};
request.onerror = () => {
resolve(false);
};
});
if (exists) {
db.close();
return true;
}
}
db.close();
return false;
} catch (err) {
console.error('Error checking if book exists:', err);
return false;
}
}
/**
* Delete a book from IndexedDB
*/
async deleteBook(bookId: string): Promise<void> {
try {
const db = await initializeDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(bookId);
request.onsuccess = () => {
resolve();
};
request.onerror = (event: Event) => {
console.error('Error deleting book from IndexedDB:', (event.target as IDBRequest).error);
reject(new Error('Failed to delete the book. Please try again.'));
};
transaction.oncomplete = () => {
db.close();
};
});
} catch (err) {
console.error('Error deleting book:', err);
throw new Error('Failed to delete the book. Please try again.');
}
}
/**
* Import a local file
*/
async importLocalFile(file: File): Promise<EpubFile> {
try {
// Generate a unique ID for the imported book
const importId = `import-${Date.now()}-${file.name}`;
// Create book metadata
const bookData: EpubFile = {
id: importId,
originalId: importId,
filename: file.name,
title: file.name.replace(/\.[^/.]+$/, ""), // Remove extension
size: file.size,
data: file,
dateAdded: new Date().getTime(),
isRemote: false,
isLocal: true,
isImported: true,
};
// Store the book
await this.storeBook(bookData);
return bookData;
} catch (err) {
console.error('Error importing file:', err);
throw err;
}
}
}

View file

@ -0,0 +1,55 @@
// src/services/remoteBookService.ts
import { type EpubFile } from '../types/epubFile';
export class RemoteBookService {
private apiBaseUrl: string;
constructor(apiBaseUrl: string) {
this.apiBaseUrl = apiBaseUrl;
}
/**
* Fetch list of available EPUB files from the API
*/
async fetchEpubList(): Promise<EpubFile[]> {
try {
const response = await fetch(`${this.apiBaseUrl}/epub-library`);
if (!response.ok) {
throw new Error(`Failed to fetch library data: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return (data.epubs || []).map((book: any) => ({
...book,
id: `remote-${book.id}`,
isRemote: true,
isLocal: false,
isImported: true,
}));
} catch (err) {
console.error('Error fetching EPUB list:', err);
throw err;
}
}
/**
* Download a remote book
*/
async downloadBook(book: EpubFile): Promise<Blob> {
try {
// Construct URL for the book
const bookUrl = `${this.apiBaseUrl}/epub/${book.originalId || book.id.replace('remote-', '')}/${book.filename}`;
// Fetch the EPUB file
const response = await fetch(bookUrl);
if (!response.ok) {
throw new Error(`Failed to download the book: ${response.status} ${response.statusText}`);
}
return await response.blob();
} catch (err) {
console.error('Error downloading book:', err);
throw err;
}
}
}

15
src/types/epubFile.ts Normal file
View file

@ -0,0 +1,15 @@
// src/types/epubFile.ts
export interface EpubFile {
id: string; // Unique identifier (prefixed with 'local-', 'remote-', or 'import-')
originalId?: string; // Original ID from the server, if applicable
filename: string; // Filename with extension
title: string; // Display title (typically filename without extension)
path?: string; // File path on the server (if remote)
size?: number; // File size in bytes
url?: string; // URL to download the book (if remote)
data?: Blob | File | ArrayBuffer;// Actual binary data
dateAdded?: number; // Timestamp when added to library
isLocal?: boolean; // Flag for books in local storage
isRemote?: boolean; // Flag for books from remote API
isImported?: boolean; // Flag for books imported from user's filesystem
}

11
src/types/i18n.ts Normal file
View file

@ -0,0 +1,11 @@
// types/i18n.ts
export interface TranslationMessages {
[key: string]: string | TranslationMessages;
}
export interface I18nConfig {
fallbackLocale: string;
messages: {
[locale: string]: TranslationMessages;
};
}

42
src/types/styles.ts Normal file
View file

@ -0,0 +1,42 @@
// src/types/styles.ts
export interface RenditionTheme {
themes: {
register: (name: string, styles: string) => void;
select: (name: string) => void;
unregister?: (name: string) => void;
update?: (name: string) => void;
override: (property: string, value: string | object) => void;
fontSize: (size: string) => void;
};
views?: () => Array<any>;
next?: () => void;
prev?: () => void;
[key: string]: any;
}
export interface StylesOptions {
initialTextColor?: string;
initialBackgroundColor?: string;
initialAccentColor?: string;
initialFontFamily?: string;
initialFontSize?: string;
onStyleChange?: (
textColor: string,
backgroundColor: string,
accentColor: string,
fontFamily: string,
fontSize: string
) => void;
}
export interface FontOption {
label: string;
value: string;
}
export interface PresetOption {
name: string;
value: string;
bgColor: string;
textColor: string;
}

View file

@ -0,0 +1,47 @@
/**
* Thanks to Xyfir
* https://github.com/Xyfir/xyfir-reader
*/
/**
* Listen for clicks and convert them to actions based on the location of the
* click on the page.
* @param {Document} document - The document to add event listeners to.
* @param {Object} rendition - EPUBJS rendition
* @param {function} fn - The listener function.
*/
import { Rendition } from 'epubjs'
type epubEvent = MouseEvent & { ignore?: boolean }
type Direction = 'next' | 'prev'
export default function mouseListener(
document: Document,
rendition: Rendition,
fn: (dire: Direction) => void
) {
document.addEventListener(
'click',
(event: epubEvent) => {
if (event.ignore) return
event.ignore = true
// User selected text
if (document?.getSelection()?.toString()) return
// Get book iframe window's size
const wX = document.body.clientWidth
// const wY = document.body.clientHeight;
// Get click location
const cX = event.clientX - 0
// const cY = event.clientY;
// Click was in left 20% of page
if (cX < wX * 0.2) fn('prev')
// Click was in right 20% of page
else if (cX > wX - wX * 0.2) fn('next')
},
false
)
}

View file

@ -0,0 +1,27 @@
/**
* Listen for key press
* @param {HTMLElement} el - The element to add event listeners to.
* @param {function} fn - The listener function.
*/
type Direction = 'next' | 'prev'
export default function keyListener(
el: HTMLElement,
fn: (dire: Direction) => void
) {
el.addEventListener(
'keyup',
(e: KeyboardEvent) => {
// Right or up arrow key indicates next
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
fn('next')
}
// left or down arrow key indicates next
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
fn('prev')
}
},
false
)
}

View file

@ -0,0 +1,12 @@
import clickListener from './click'
import keyListener from './key'
import wheelListener from './wheel'
import swipListener from './swip'
import selectListener from './select'
export {
clickListener,
keyListener,
wheelListener,
swipListener,
selectListener,
}

View file

@ -0,0 +1,38 @@
/**
* @param {Document} document - The document object to add event
* @param {Object} rendition - The EPUBJS rendition
* @param {Function} fb - The listener function
*/
export default function selectListener(document, rendition, fn) {
document.addEventListener('mousedown', () => {
document.getSelection().removeAllRanges()
fn('cleared')
})
document.addEventListener('mouseup', (e) => {
if (e.ignore) return
e.ignore = true
const selection = document.getSelection()
const text = selection.toString()
if (text === '') return
const range = selection.getRangeAt(0)
const [contents] = rendition.getContents()
const cfiRange = contents.cfiFromRange(range)
const SelectionReact = range.getBoundingClientRect()
const viewRect = rendition.manager.container.getBoundingClientRect()
let react = {
left: `${
viewRect.x + SelectionReact.x - (rendition.manager.scrollLeft || 0)
}px`,
top: `${viewRect.y + SelectionReact.y}px`,
width: `${SelectionReact.width}px`,
height: `${SelectionReact.height}px`,
}
fn('selected', react, text, cfiRange)
})
}

View file

@ -0,0 +1,84 @@
/**
* Thanks to Xyfir
* https://github.com/Xyfir/xyfir-reader
*/
/**
* Listen for swipes convert them to actions.
* @param {Document} document - The document to add event listeners to.
* @param {function} fn - The listener function.
*/
type epubEvent = TouchEvent & { ignore?: boolean }
type Direction = 'next' | 'prev' | 'up' | 'down'
export default function swipListener(
document: Document,
fn: (dire: Direction) => void
) {
// Defaults: 100, 350, 100
// Required min distance traveled to be considered swipe
const threshold = 50
// Maximum time allowed to travel that distance
const allowedTime = 500
// Maximum distance allowed at the same time in perpendicular direction
const restraint = 200
let startX: number
let startY: number
let startTime: number
document.addEventListener(
'touchstart',
(e: epubEvent) => {
if (e.ignore) return
e.ignore = true
startX = e.changedTouches[0].pageX
startY = e.changedTouches[0].pageY
startTime = Date.now()
},
false
)
document.addEventListener(
'touchend',
(e: epubEvent) => {
if (e.ignore) return
e.ignore = true
// Get distance traveled by finger while in contact with surface
const distX = e.changedTouches[0].pageX - startX
const distY = e.changedTouches[0].pageY - startY
// Time elapsed since touchstart
const elapsedTime = Date.now() - startTime
if (elapsedTime <= allowedTime) {
// Horizontal swipe
if (Math.abs(distX) >= threshold && Math.abs(distY) <= restraint)
// If dist traveled is negative, it indicates right swipe
fn(distX < 0 ? 'next' : 'prev')
// Vertical swipe
else if (Math.abs(distY) >= threshold && Math.abs(distX) <= restraint)
// If dist traveled is negative, it indicates up swipe
fn(distY < 0 ? 'up' : 'down')
// Tap
else {
document?.defaultView?.getSelection()?.removeAllRanges()
// Convert tap to click
document.dispatchEvent(
new MouseEvent('click', {
clientX: startX,
clientY: startY,
})
)
// !! Needed to prevent double 'clicks' in certain environments
e.preventDefault()
}
}
},
false
)
}

View file

@ -0,0 +1,40 @@
/**
* Listen for wheel and convert them to next or prev action based on direction.
* @param {HTMLElement} el - The element to add event listeners to.
* @param {function} fn - The listener function.
*/
type epubEvent = WheelEvent & { ignore?: boolean }
type Direction = 'next' | 'prev'
export default function wheelListener(
el: HTMLElement,
fn: (dire: Direction) => void
) {
// Required min distance traveled to be considered swipe
const threshold = 750
// Maximum time allowed to travel that distance
const allowedTime = 50
let dist: number = 0
let isScrolling: NodeJS.Timeout
el.addEventListener('wheel', (e: epubEvent) => {
if (e.ignore) return
e.ignore = true
clearTimeout(isScrolling)
dist += e.deltaY
isScrolling = setTimeout(() => {
if (Math.abs(dist) >= threshold) {
// If wheel scrolled down it indicates left
let direction: Direction = Math.sign(dist) > 0 ? 'next' : 'prev'
fn(direction)
dist = 0
}
dist = 0
}, allowedTime)
})
}

176
src/utils/utils.ts Normal file
View file

@ -0,0 +1,176 @@
// src/utils/utils.ts
import { ref } from 'vue';
import { type EpubFile } from '../types/epubFile';
/**
* Constants
*/
export const DB_NAME = 'epubLibrary';
export const DB_VERSION = 1;
export const STORE_NAME = 'books';
/**
* Initialize IndexedDB
*/
export const initializeDB = (): Promise<IDBDatabase> => {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = (event: Event) => {
const db = (event.target as IDBOpenDBRequest).result;
resolve(db);
};
request.onerror = (event: Event) => {
console.error('IndexedDB initialization error:', (event.target as IDBOpenDBRequest).error);
reject(new Error('Could not access local storage. Please check your browser settings.'));
};
});
};
/**
* Format a filename to be more readable
* Removes file extension and replaces underscores/hyphens with spaces
*/
export const formatFilename = (filename: string): string => {
if (!filename) return '';
// Remove file extension
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
// Replace underscores and hyphens with spaces
const formattedName = nameWithoutExt.replace(/[_-]/g, ' ');
// Capitalize first letter of each word
return formattedName
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
/**
* Create a toast notification system
*/
export const createToast = () => {
const toast = ref<{ message: string | null; type: string }>({ message: null, type: 'info' });
const showToast = (message: string, type = 'info', duration = 3000) => {
toast.value = { message, type };
setTimeout(() => {
toast.value.message = null;
}, duration);
};
return { toast, showToast };
};
export const loadBookFromIndexedDB = async (bookId: string): Promise<EpubFile> => {
try {
const db = await initializeDB();
return new Promise<EpubFile>((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(bookId);
request.onsuccess = () => {
const result = request.result;
if (result) {
resolve(result as EpubFile);
} else {
reject(new Error('Book not found in your library.'));
}
db.close();
};
request.onerror = (event: Event) => {
console.error('Error loading book from IndexedDB:', (event.target as IDBRequest).error);
reject(new Error('Failed to load book from your library.'));
db.close();
};
});
} catch (err) {
console.error('Error accessing IndexedDB:', err);
throw err;
}
};
/**
* Check if a book exists in IndexedDB
*/
export const checkBookInIndexedDB = async (book: EpubFile): Promise<boolean> => {
try {
const db = await initializeDB();
return new Promise<boolean>((resolve) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
// Handle both imported books and server books
if (book.isImported) {
// For imported books, the ID is already the full unique ID
const request = store.get(book.id.toString());
request.onsuccess = () => {
resolve(!!request.result);
db.close();
};
request.onerror = () => {
console.error('Error checking imported book in IndexedDB:', request.error);
resolve(false);
db.close();
};
} else {
// For server books, construct the ID
const uniqueId = `${book.id}-${book.filename}`;
const request = store.get(uniqueId);
request.onsuccess = () => {
resolve(!!request.result);
db.close();
};
request.onerror = () => {
console.error('Error checking server book in IndexedDB:', request.error);
resolve(false);
db.close();
};
}
});
} catch (err) {
console.error('Error accessing IndexedDB:', err);
return false;
}
};
/**
* Format a file size in bytes to a human-readable string
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
/**
* Format a date to a human-readable string
*/
export const formatDate = (date: Date | number): string => {
if (!date) return '';
const d = new Date(date);
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};

476
src/views/LibraryView.vue Normal file
View file

@ -0,0 +1,476 @@
<!-- src/views/LibraryView.vue -->
<template>
<div class="library-wrapper" :class="{ slideLeft: stylesModalOpen }">
<div class="library-header">
<h4>{{ t('library.library') }}</h4>
<StylesButton
:is-open="stylesModalOpen"
@toggle="toggleStylesModal"
/>
</div>
<div class="library-container">
<div class="import-section">
<input
type="file"
id="fileInput"
ref="fileInput"
accept=".epub"
multiple
style="display: none"
@change="handleFileImport"
/>
<button class="import-button" @click="triggerFileInput">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M21 14a1 1 0 0 0-1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-4a1 1 0 0 0-2 0v4a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-4a1 1 0 0 0-1-1m-9.71 1.71a1 1 0 0 0 .33.21a.94.94 0 0 0 .76 0a1 1 0 0 0 .33-.21l4-4a1 1 0 0 0-1.42-1.42L13 12.59V3a1 1 0 0 0-2 0v9.59l-2.29-2.3a1 1 0 1 0-1.42 1.42Z"/></svg>
</button>
</div>
<div v-if="loading" class="loading">
{{ t('library.loading') }}
</div>
<div v-else-if="epubFiles.length === 0" class="empty-library">
<div class="empty-icon">📚</div>
<p>{{ t('library.emptyLibrary') }}</p>
</div>
<div v-else class="book-grid">
<BookCard
v-for="book in epubFiles"
:key="book.id"
:book="book"
@read="openReader"
@book-updated="handleBookUpdated"
@book-deleted="handleBookDeleted"
/>
</div>
</div>
<div v-if="toast.message" :class="['toast', toast.type]">{{ toast.message }}</div>
</div>
<StylesModal
v-model:text-color="textColor"
v-model:background-color="backgroundColor"
v-model:accent-color="accentColor"
v-model:font-family="fontFamily"
:is-open="stylesModalOpen"
@close="toggleStylesModal"
:title="t('settings.settings')"
/>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import StylesModal from '../components/StylesModal.vue';
import StylesButton from '../components/StylesButton.vue'
import BookCard from '../components/BookCard.vue';
import { useI18n } from '../i18n/usei18n';
import { useStyles } from '../composables/useStyles';
import { type EpubFile } from '../types/epubFile';
import {
formatFilename,
createToast
} from '../utils/utils';
import { RemoteBookService } from '../services/remoteBookService';
import { ImportBookService } from '../services/importBookService';
export default defineComponent({
name: 'LibraryView',
components: {
BookCard,
StylesModal,
StylesButton
},
setup() {
const { t } = useI18n();
const router = useRouter();
const epubFiles = ref<EpubFile[]>([]);
const loading = ref<boolean>(true);
const error = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const { toast, showToast } = createToast();
// Use composable for styles management
const {
textColor,
backgroundColor,
accentColor,
fontFamily,
stylesModalOpen,
toggleStylesModal
} = useStyles();
// Environment configuration
const useRemoteApi = computed(() => {
return import.meta.env.VITE_REMOTE_API === 'true';
});
const apiBaseUrl = computed(() => {
return import.meta.env.VITE_API_URL;
});
// Create services
const importBookService = new ImportBookService();
const remoteBookService = new RemoteBookService(apiBaseUrl.value);
// Load the library on component mount
const loadLibrary = async (): Promise<void> => {
loading.value = true;
error.value = null;
document.title = t('library.library')
try {
// Get locally imported books first
const localBooks = await importBookService.loadLocalBooks();
let allBooks = [...localBooks];
// If remote API is enabled, fetch those books too
if (useRemoteApi.value) {
try {
const remoteBooks = await remoteBookService.fetchEpubList();
// Filter out remote books that are already in local storage
for (const remoteBook of remoteBooks) {
const alreadyExists = localBooks.some(localBook => {
const remoteOriginalId = remoteBook.originalId || remoteBook.id.replace('remote-', '');
const localOriginalId = localBook.originalId || localBook.id.replace('local-', '');
return remoteOriginalId === localOriginalId ||
remoteBook.filename === localBook.filename;
});
if (!alreadyExists) {
allBooks.push(remoteBook);
}
}
} catch (err) {
console.error('Error fetching remote EPUB list:', err);
showToast('Could not retrieve online library. Showing local books only.', 'error');
}
}
// Sort by date added, newest first
allBooks.sort((a, b) => {
return (b.dateAdded || 0) - (a.dateAdded || 0);
});
epubFiles.value = allBooks;
} catch (err) {
console.error('Error loading library:', err);
showToast('Failed to load library. Please try again later.', 'error');
epubFiles.value = []; // Ensure books is at least an empty array
} finally {
loading.value = false;
}
};
// Function to open the reader for a book
const openReader = async (book: EpubFile): Promise<void> => {
try {
// For local books, open directly
if (book.isLocal) {
router.push(`/reader/${book.id}`);
return;
}
// For remote books, check if already downloaded
if (book.isRemote) {
if (await importBookService.bookExistsInDB(book)) {
// Book is already in local storage, find its local ID
const localBooks = await importBookService.loadLocalBooks();
const localBook = localBooks.find(localBook => {
const remoteId = book.originalId || book.id.replace('remote-', '');
const localOriginalId = localBook.originalId || localBook.id.replace('local-', '');
return remoteId === localOriginalId || book.filename === localBook.filename;
});
if (localBook) {
router.push(`/reader/${localBook.id}`);
return;
}
}
// Need to download the book first
showToast(`Downloading "${formatFilename(book.filename)}"...`, 'info');
try {
// Download the book
const blob = await remoteBookService.downloadBook(book);
// Store in IndexedDB
const localId = await importBookService.storeBook(book, blob);
showToast(`"${formatFilename(book.filename)}" has been downloaded to your local library.`, 'success');
// Open the reader
router.push(`/reader/${localId}`);
} catch (err) {
console.error('Error downloading book:', err);
showToast(`Failed to download the book: ${err instanceof Error ? err.message : String(err)}`, 'error');
}
}
} catch (err) {
console.error('Error preparing book for reading:', err);
showToast(`Error preparing book for reading: ${err instanceof Error ? err.message : String(err)}`, 'error');
}
};
// Function to trigger file input click
const triggerFileInput = (): void => {
if (fileInput.value) {
fileInput.value.click();
}
};
// Handle file import from user's filesystem
const handleFileImport = async (event: Event): Promise<void> => {
const target = event.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) {
return;
}
try {
let importedCount = 0;
const failedImports: Array<{name: string, reason: string}> = [];
// Process each file
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Check if the file is an EPUB
if (!file.name.toLowerCase().endsWith('.epub')) {
failedImports.push({ name: file.name, reason: 'Not an EPUB file' });
continue;
}
try {
// Import the file using the service
const importedBook = await importBookService.importLocalFile(file);
// Add the imported book to the current view
epubFiles.value = [...epubFiles.value, importedBook];
importedCount++;
} catch (err) {
console.error('Error processing file:', file.name, err);
failedImports.push({ name: file.name, reason: 'Processing error' });
}
}
// Reset file input
target.value = '';
// Show import summary
if (importedCount > 0) {
const message = `Successfully imported ${importedCount} book${importedCount !== 1 ? 's' : ''}.`;
showToast(message, 'success');
}
if (failedImports.length > 0) {
let failMessage = `Failed to import ${failedImports.length} file${failedImports.length !== 1 ? 's' : ''}`;
showToast(failMessage, 'error');
}
} catch (err) {
console.error('Import error:', err);
showToast(`Error importing books: ${err instanceof Error ? err.message : String(err)}`, 'error');
}
};
// Handle book updated event
const handleBookUpdated = (updatedBook: EpubFile): void => {
// Find the book in the list and update it
const index = epubFiles.value.findIndex(b => b.id === updatedBook.id);
if (index !== -1) {
epubFiles.value[index] = updatedBook;
// Create a new array to trigger reactivity
epubFiles.value = [...epubFiles.value];
showToast(`Book "${updatedBook.title || formatFilename(updatedBook.filename)}" has been updated.`, 'success');
}
};
// Handle book deleted event
const handleBookDeleted = (bookId: string): void => {
// Find the book first to get its title for the toast message
const deletedBook = epubFiles.value.find(b => b.id === bookId);
const bookTitle = deletedBook ? (deletedBook.title || formatFilename(deletedBook.filename)) : 'Book';
// Remove the book from the list
epubFiles.value = epubFiles.value.filter(b => b.id !== bookId);
showToast(`"${bookTitle}" has been removed from your library.`, 'success');
};
onMounted(() => {
loadLibrary();
});
return {
t,
epubFiles,
loading,
error,
fileInput,
openReader,
triggerFileInput,
handleFileImport,
handleBookUpdated,
handleBookDeleted,
toast,
showToast,
// Styles from composable
textColor,
backgroundColor,
accentColor,
stylesModalOpen,
fontFamily,
toggleStylesModal
};
}
});
</script>
<style scoped>
.library-wrapper{
transition: all 0.3s ease-in-out;
}
.library-container {
padding: 1rem 2rem;
color: var(--text-color, #000000);
background-color: var(--background-color, #ffffff);
max-width: 1200px;
margin: 0 auto;
}
.library-header {
background: var(--background-color);
display: flex;
align-items: center;
padding: 5px;
justify-content: space-between;
color: var(--text-color);
margin: 0;
border-bottom: 1px solid var(--divider-color);
}
.library-header h4 {
margin: 0;
font-weight: bold;
padding: 0 0 0 0.5rem;
}
.slideLeft {
transform: translateX(-256px);
}
.import-section {
position: fixed;
bottom: 1rem;
left: 1rem;
z-index: 999;
transition: transform 0.2s;
}
.import-section:active {
transform: scale(0.9);
}
.import-button {
background-color: var(--accent-color, #f5a623);
color: white;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, box-shadow 0.2s;
}
.import-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
.import-button:active {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Status Messages */
.loading, .error, .empty-library {
text-align: center;
padding: 40px;
font-size: 18px;
color: var(--text-color);
}
.empty-library {
padding: 60px 20px;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
color: var(--text-color);
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
margin-top: 2rem;
}
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
border-radius: 8px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
animation: fadeIn 0.3s, fadeOut 0.3s 3.7s;
max-width: 90%;
}
.toast.success {
background-color: var(--green);
color: white;
}
.toast.error {
background-color: var(--red);
color: white;
}
.toast.info {
background-color: var(--blue);
color: white;
}
@keyframes fadeIn {
from { opacity: 0; transform: translate(-50%, 20px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes fadeOut {
from { opacity: 1; transform: translate(-50%, 0); }
to { opacity: 0; transform: translate(-50%, 20px); }
}
@media (max-width: 768px) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.library-container {
padding: 1rem;
}
}
</style>

574
src/views/ReaderView.vue Normal file
View file

@ -0,0 +1,574 @@
<!-- ReaderView.vue -->
<template>
<div class="reader-container">
<div
class="reader-area"
:class="{
slideRight: expandedToc,
slideLeft: stylesModalOpen
}"
>
<!-- <button class="back-btn" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20v-2z"/>
</svg>
</button> -->
<button
v-if="showToc"
class="toc-button"
:class="{ 'toc-button-expanded': expandedToc }"
@click="toggleToc"
>
<span class="toc-button-bar" style="top: 35%"></span>
<span class="toc-button-bar" style="top: 66%"></span>
</button>
<h2 class="book-title">{{ bookTitle }}</h2>
<StylesButton
:is-open="stylesModalOpen"
@toggle="toggleStylesModal"
/>
<div v-if="loading" class="loading">
{{ t("reader.loading") }}
</div>
<div v-else-if="error" class="error">
{{ error }}
</div>
<div v-else class="reader-view">
<EpubView
ref="epubRef"
v-if="bookData"
:url="bookData"
:location="location"
@update:location="locationChange"
:tocChanged="onTocChange"
:getRendition="getRendition"
/>
</div>
</div>
<!-- Table of Contents -->
<div v-if="showToc">
<div class="toc-area" v-show="expandedToc">
<TocComponent
:toc="toc"
:current="currentHref"
:setLocation="setLocation"
/>
</div>
<!-- TOC Background Overlay -->
<div v-if="expandedToc" class="toc-background" @click="toggleToc"></div>
</div>
<StylesModal
v-model:text-color="textColor"
v-model:background-color="backgroundColor"
v-model:accent-color="accentColor"
v-model:font-family="fontFamily"
v-model:font-size="fontSize"
:is-open="stylesModalOpen"
@close="toggleStylesModal"
:title="t('settings.settings')"
/>
</div>
</template>
<script setup lang="ts">
import {
ref,
reactive,
onMounted,
onUnmounted,
toRefs,
h,
getCurrentInstance,
Transition,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "../i18n/usei18n";
import StylesModal from "../components/StylesModal.vue";
import StylesButton from '../components/StylesButton.vue'
import { useStyles } from "../composables/useStyles";
import { loadBookFromIndexedDB, formatFilename } from "../utils/utils";
import type { RenditionTheme } from "../types/styles";
import EpubView from "../components/EpubView.vue";
import { type EpubFile } from "../types/epubFile";
// NavItem interface
interface NavItem {
id: string;
href: string;
label: string;
subitems: Array<NavItem>;
parent?: string;
expansion: boolean;
}
// TocComponent definition - Using setup function within script setup
const TocComponent = (props: {
toc: Array<NavItem>;
current: string | number;
setLocation: (href: string | number, close?: boolean) => void;
isSubmenu?: boolean;
}) => {
const vm = getCurrentInstance();
const renderH = h.bind(vm);
return renderH(
"div",
null,
props.toc.map((item, index) => {
return renderH("div", { key: index }, [
renderH(
"button",
{
class: [
"toc-area-button",
item.href === props.current ? "active" : "",
],
onClick: () => {
if (item.subitems.length > 0) {
item.expansion = !item.expansion;
props.setLocation(item.href, false);
} else {
props.setLocation(item.href);
}
},
},
[
props.isSubmenu ? " ".repeat(4) + item.label : item.label,
// Expansion indicator
item.subitems &&
item.subitems.length > 0 &&
renderH("div", {
class: `${item.expansion ? "open" : ""} expansion`,
}),
]
),
// Nested TOC
item.subitems &&
item.subitems.length > 0 &&
renderH(
Transition,
{ name: "collapse-transition" },
{
default: () =>
renderH(
"div",
{
style: {
display: item.expansion ? undefined : "none",
},
},
[
renderH(TocComponent, {
toc: item.subitems,
current: props.current,
setLocation: props.setLocation,
isSubmenu: true,
}),
]
),
}
),
]);
})
);
};
// Setup and state management
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const loading = ref<boolean>(true);
const error = ref<string | null>(null);
const bookData = ref<ArrayBuffer | null>(null);
const bookDataUrl = ref<string | null>(null); // Add this to store URL for comparison
const bookTitle = ref<string>("");
const location = ref<string | null>(null);
const firstRenderDone = ref<boolean>(false);
const showToc = ref<boolean>(true);
const epubRef = ref<InstanceType<typeof EpubView> | null>(null); // Add null type for initialization
const currentHref = ref<string | number | null>(null);
// TOC related state
const bookState = reactive({
toc: [] as Array<NavItem>,
expandedToc: false,
});
const { toc, expandedToc } = toRefs(bookState);
const {
textColor,
backgroundColor,
accentColor,
fontFamily,
fontSize,
stylesModalOpen,
toggleStylesModal,
rendition,
setRendition,
} = useStyles();
const loadBook = async (): Promise<void> => {
loading.value = true;
error.value = null;
try {
const bookId = route.params.bookId as string;
if (!bookId) throw new Error("No book ID provided.");
const book: EpubFile = await loadBookFromIndexedDB(bookId);
const progressKey = `book-progress-${bookId}`;
const savedLocation = localStorage.getItem(progressKey);
if (savedLocation) location.value = savedLocation;
if (book.data instanceof Blob) {
bookData.value = await book.data.arrayBuffer();
// Create object URL for XHR comparison
const blob = new Blob([bookData.value]);
bookDataUrl.value = URL.createObjectURL(blob);
} else if (book.data instanceof ArrayBuffer) {
bookData.value = book.data;
// Create object URL for XHR comparison
const blob = new Blob([bookData.value]);
bookDataUrl.value = URL.createObjectURL(blob);
} else {
throw new Error("Book data is in an unsupported format");
}
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error("Error loading book:", err);
error.value = `Failed to load the book. ${errorMsg}`;
} finally {
loading.value = false;
}
};
const locationChange = (epubcifi: string): void => {
if (!firstRenderDone.value) {
firstRenderDone.value = true;
return;
}
const bookId = route.params.bookId as string;
localStorage.setItem(`book-progress-${bookId}`, epubcifi);
location.value = epubcifi;
};
const getRendition = (renditionObj: any): void => {
setRendition(renditionObj as RenditionTheme);
// Track current location for TOC highlighting
renditionObj.on("relocated", (location: { start: { href: string } }) => {
currentHref.value = location.start.href;
});
// Get book metadata
const book = renditionObj.book;
book.ready.then(() => {
const meta = book.package.metadata;
if (!bookTitle.value && meta.title) {
bookTitle.value = meta.title;
document.title = meta.title;
}
});
};
const goBack = (): void => {
router.push("/");
};
const toggleToc = (): void => {
expandedToc.value = !expandedToc.value;
if (expandedToc.value) {
window.addEventListener('keydown', handleKeyDown);
} else {
window.removeEventListener('keydown', handleKeyDown);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
expandedToc.value = false;
}
};
const onTocChange = (tocData: any[]): void => {
// Convert epubjs NavItem to our NavItem with expansion property
toc.value = tocData.map((i) => ({
...i,
expansion: false,
// Ensure subitems is always an array
subitems: Array.isArray(i.subitems) ? i.subitems.map((s: any) => ({ ...s, expansion: false })) : []
}));
};
const setLocation = (
href: string | number,
close: boolean = true
): void => {
epubRef.value?.setLocation(href);
currentHref.value = href;
expandedToc.value = !close;
};
// XHR Progress tracking
const originalOpen = XMLHttpRequest.prototype.open;
const onProgress = (e: ProgressEvent) => {
// You could emit a progress event here if needed
// emit('progress', Math.floor((e.loaded / e.total) * 100));
};
XMLHttpRequest.prototype.open = function (
method: string,
requestUrl: string | URL
) {
if (bookDataUrl.value && requestUrl.toString() === bookDataUrl.value) {
this.addEventListener("progress", onProgress);
}
originalOpen.apply(this, arguments as any);
};
onMounted(() => {
loadBook();
});
onUnmounted(() => {
// Clean up object URL to prevent memory leaks
if (bookDataUrl.value) {
URL.revokeObjectURL(bookDataUrl.value);
}
XMLHttpRequest.prototype.open = originalOpen;
if (expandedToc.value) {
window.removeEventListener('keydown', handleKeyDown);
}
});
defineExpose({
t,
loading,
error,
bookData,
bookTitle,
location,
goBack,
locationChange,
getRendition,
rendition,
fontFamily,
fontSize,
stylesModalOpen,
toggleStylesModal,
textColor,
backgroundColor,
accentColor,
// TOC related
showToc,
toc,
expandedToc,
toggleToc,
onTocChange,
currentHref,
setLocation,
epubRef,
TocComponent,
});
</script>
<style>
.toc-area {
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 0 !important;
width: 256px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 6px 0;
background-color: var(--background-color) !important;
border-right: 1px solid var(--divider-color) !important;
}
.toc-area::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.toc-area::-webkit-scrollbar-thumb:vertical {
height: 5px;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
}
.toc-area .toc-area-button {
user-select: none;
appearance: none;
background: none;
border: none;
display: block;
font-family: sans-serif;
width: 100%;
font-size: 0.9em;
text-align: left;
padding: 0.9em 1em;
border-bottom: 1px solid var(--divider-color) !important;
color: var(--text-color) !important;
box-sizing: border-box;
outline: none;
cursor: pointer;
position: relative;
}
.toc-area .toc-area-button:hover {
background: rgba(0, 0, 0, 0.05);
}
.toc-area .toc-area-button:active {
background: rgba(0, 0, 0, 0.1);
}
.toc-area .active {
border-left: 3px solid var(--accent-color) !important;
}
.toc-area .toc-area-button .expansion {
cursor: pointer;
transform: translateY(-50%);
top: 50%;
right: 12px;
position: absolute;
width: 10px;
background-color: #a2a5b4;
transition: top 0.3s ease-in-out;
}
.toc-area .toc-area-button .expansion::after,
.toc-area .toc-area-button .expansion::before {
content: "";
position: absolute;
width: 6px;
height: 2px;
background-color: currentcolor;
border-radius: 2px;
transition: transform 0.3s ease-in-out, top 0.3s ease-in-out;
}
.toc-area .toc-area-button .expansion::before {
transform: rotate(-45deg) translateX(2.5px);
}
.toc-area .toc-area-button .open::before {
transform: rotate(45deg) translateX(2.5px);
}
.toc-area .toc-area-button .expansion::after {
transform: rotate(45deg) translateX(-2.5px);
}
.toc-area .toc-area-button .open::after {
transform: rotate(-45deg) translateX(-2.5px);
}
</style>
<style scoped>
.container {
overflow: hidden;
position: relative;
height: 100%;
}
.slideRight {
transform: translateX(256px);
}
.slideLeft {
transform: translateX(-256px);
}
.reader-area {
position: relative;
z-index: 999;
height: 100%;
width: 100%;
background-color: var(--background-color) !important;
transition: all 0.3s ease-in-out;
}
.toc-button {
color: var(--accent-color);
transition: transform ease-in 0.3s;
background: none;
border: none;
border-radius: 2px;
cursor: pointer;
height: 32px;
width: 32px;
outline: none;
position: absolute;
top: 6px;
}
.toc-button {
left: 6px;
}
.toc-button-bar {
position: absolute;
width: 60%;
background: var(--accent-color) !important;
height: 2px;
left: 50%;
margin: -1px -30%;
top: 50%;
transition: all 0.3s ease-in-out;
}
.toc-button-expanded > .toc-button-bar:first-child {
top: 50% !important;
transform: rotate(45deg);
}
.toc-button-expanded > .toc-button-bar:last-child {
top: 50% !important;
transform: rotate(-45deg);
}
/* loading */
.loading-view {
position: absolute;
top: 50%;
left: 10%;
right: 10%;
color: var(--accent-color);
text-align: center;
margin-top: -0.5em;
}
.reader-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
overflow: hidden;
color: var(--text-color, #000000);
background-color: var(--background-color, #ffffff);
}
.book-title {
margin: 0 1rem;
font-size: 1rem;
color: var(--text-color, #000000);
opacity: 0.7;
left: 50px;
overflow: hidden;
position: absolute;
right: 50px;
text-align: center;
text-overflow: ellipsis;
top: 10px;
white-space: nowrap;
}
.reader-view {
transition: all 0.3s ease-in-out;
overflow: hidden;
}
.loading,
.error {
display: flex;
justify-content: center;
align-items: center;
color: var(--accent-color);
height: 100%;
}
</style>

12
tsconfig.app.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
tsconfig.node.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

25
vite.config.ts Normal file
View file

@ -0,0 +1,25 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
//import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
base: String(env.VITE_BASE_URL),
plugins: [
vue(),
//vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
host: '0.0.0.0',
port: Number(env.VITE_PORT),
},
}
})