upload to github
This commit is contained in:
commit
92cc81e813
51 changed files with 8041 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal 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
63
.github/workflows/static.yaml
vendored
Normal 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
31
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
55
README.md
Normal 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
18
docker-compose.yaml
Normal 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
12
env.d.ts
vendored
Normal 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
13
index.html
Normal 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
3463
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal 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
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 860 B |
BIN
public/fonts/Fast_Mono.ttf
Normal file
BIN
public/fonts/Fast_Mono.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Fast_Sans.ttf
Normal file
BIN
public/fonts/Fast_Sans.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Fast_Sans_Dotted.ttf
Normal file
BIN
public/fonts/Fast_Sans_Dotted.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Fast_Serif.ttf
Normal file
BIN
public/fonts/Fast_Serif.ttf
Normal file
Binary file not shown.
7
src/App.vue
Executable file
7
src/App.vue
Executable 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
73
src/assets/base.css
Executable 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
41
src/assets/main.css
Executable 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
245
src/components/BookCard.vue
Normal 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>
|
||||
378
src/components/BookSettingsModal.vue
Normal file
378
src/components/BookSettingsModal.vue
Normal 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
296
src/components/EpubView.vue
Normal 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>
|
||||
99
src/components/FontSelector.vue
Normal file
99
src/components/FontSelector.vue
Normal 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>
|
||||
183
src/components/FontSizeSelector.vue
Normal file
183
src/components/FontSizeSelector.vue
Normal 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>
|
||||
96
src/components/LanguageSelector.vue
Normal file
96
src/components/LanguageSelector.vue
Normal 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>
|
||||
87
src/components/RgbColorPicker.vue
Normal file
87
src/components/RgbColorPicker.vue
Normal 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>
|
||||
82
src/components/StylesButton.vue
Normal file
82
src/components/StylesButton.vue
Normal 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>
|
||||
375
src/components/StylesModal.vue
Normal file
375
src/components/StylesModal.vue
Normal 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>
|
||||
279
src/composables/useStyles.ts
Normal file
279
src/composables/useStyles.ts
Normal 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
9
src/i18n/index.ts
Normal 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
91
src/i18n/translations.ts
Normal 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
91
src/i18n/usei18n.ts
Normal 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
21
src/main.ts
Executable 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
24
src/router/index.ts
Executable 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
|
||||
190
src/services/importBookService.ts
Normal file
190
src/services/importBookService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/services/remoteBookService.ts
Normal file
55
src/services/remoteBookService.ts
Normal 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
15
src/types/epubFile.ts
Normal 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
11
src/types/i18n.ts
Normal 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
42
src/types/styles.ts
Normal 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;
|
||||
}
|
||||
47
src/utils/listeners/click.ts
Normal file
47
src/utils/listeners/click.ts
Normal 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
|
||||
)
|
||||
}
|
||||
27
src/utils/listeners/key.ts
Normal file
27
src/utils/listeners/key.ts
Normal 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
|
||||
)
|
||||
}
|
||||
12
src/utils/listeners/listener.ts
Normal file
12
src/utils/listeners/listener.ts
Normal 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,
|
||||
}
|
||||
38
src/utils/listeners/select.ts
Normal file
38
src/utils/listeners/select.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
84
src/utils/listeners/swip.ts
Normal file
84
src/utils/listeners/swip.ts
Normal 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
|
||||
)
|
||||
}
|
||||
40
src/utils/listeners/wheel.ts
Normal file
40
src/utils/listeners/wheel.ts
Normal 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
176
src/utils/utils.ts
Normal 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
476
src/views/LibraryView.vue
Normal 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
574
src/views/ReaderView.vue
Normal 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
12
tsconfig.app.json
Normal 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
11
tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal 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
25
vite.config.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue