98 Commits

Author SHA1 Message Date
09f5ca3a99 fix workflow
All checks were successful
Build and Push Docker Image / docker (push) Successful in 1m45s
2025-08-28 01:31:03 -07:00
e8a804826a add workflow
Some checks failed
Build and Push Docker Image / docker (push) Has been cancelled
2025-08-28 01:23:33 -07:00
b8cef4d7b3 add postgres integration to save progress across devices 2025-08-28 01:21:28 -07:00
Shaun Tenner
fc88ee9a97 Merge pull request #68 from 4890A/main
set manifest display to fullscreen so PWAs on android can start fullscreen
2025-07-01 09:03:36 +02:00
48
2054f4677c set manifest display to fullscreen 2025-07-01 01:56:47 -04:00
Shaun Tenner
03c7b5e6d9 Merge pull request #64 from Decodatain/image-processing
Enhance image processing settings in AnkiConnect
2025-06-25 20:57:42 +02:00
decodatain
2493555faf Made changes based on ZXY101 code review 2025-06-22 20:16:15 -06:00
decodatain
2fb5d4ae0a Enhance image processing settings in AnkiConnect
- Added height, width, and quality fields to AnkiConnect settings.
- Updated imageToWebp and getCroppedImg functions to accept settings for resizing and quality.
- Integrated settings into Cropper and QuickActions components for improved image handling.

Reason Note:

The recommended settings of 200 and 0.5 make average file sizes of around 5kb. This is about the same filesize as the audio for a given word.

With maximum quality and no file size limit, an image size of 1200x1800 pixels is around 1mb.

This means 1,000 words mined with full sized pictures takes up a 1gb of space on anki.

With the recommended settings, it would take 200,000 words mined to take up a GB of space on anki.

These quality settings can be disabled by settings the max quality to 1 and max width and height to 0. The user is in full control of these changes.
2025-04-26 21:18:55 -06:00
Shaun Tenner
d174113c53 Merge pull request #62 from ZXY101/ZXY101-patch-1
Update +page.svelte
2025-04-15 15:28:26 +02:00
Shaun Tenner
e0b4e3edf0 Update +page.svelte 2025-04-15 15:28:10 +02:00
Shaun Tenner
683a456202 Merge pull request #50 from Gnathonic/improve_compatibility_with_extensions
Improve language extension compatibility
2025-02-07 19:02:32 +02:00
Shaun Tenner
9a4c86870a Merge pull request #49 from v2lmmj04/patch-1
Update underscore prefix for image filenames
2025-02-07 18:38:34 +02:00
Gnathonic
bc5b9ccd16 Add keyed blocks to enhance MangaPage reactivity
Keyed blocks improve DOM update handling. This improves compatibility with some language learning extensions. For example, this prevents Migaku from persisting textboxes from previous pages as one pages through their comics.
2025-01-24 18:19:35 -07:00
v2lmmj04
6c16008aae Change prefix to "mokuro_*"
This was an alternative I was thinking of initially since it better namespaces the files, but wasn't sure about.

I saw this commit ended up going with this approach, so I'm aligning the PR to it in the end:

6d550cbe8a
2025-01-09 19:51:49 -08:00
v2lmmj04
b281052908 Remove underscore prefix for image filenames 2025-01-05 14:51:37 -08:00
Shaun Tenner
52619b1a9b Merge pull request #42 from nyanSpruk/patch-1
Update README.md - typo
2024-11-16 10:18:42 +02:00
Nik Jan Špruk
a30c526a63 Update README.md - typo
Fixed typo from useage to usage.
2024-11-15 20:55:48 +01:00
Shaun Tenner
7fb55f5399 Merge pull request #41 from AnonMiraj/main
Add way to import from other sources
2024-11-14 19:34:43 +02:00
AnonMiraj
6220b6172e Add way to import from other sources 2024-11-13 20:54:10 +02:00
ZXY101
a9f7bca27c Fix title text 2024-08-19 22:02:24 +02:00
Shaun Tenner
0ebeb46171 Merge pull request #23 from kenrick95/kenrick/invert-colors-settings
Settings to invert colors of the images
2024-07-10 23:46:54 +02:00
Kenrick
1fdbdf5185 Settings to invert colors of the images
Useful for novels, for reading in dark mode
2024-07-08 23:15:17 +08:00
ZXY101
109241e584 Add default fullscreen setting 2024-05-25 22:30:16 +02:00
ZXY101
ec632b534b Fix progress 2024-05-18 19:25:19 +02:00
ZXY101
bc7cb721d3 Update loading 2024-05-18 17:26:12 +02:00
ZXY101
5a5dc189fb Improve download 2024-05-18 17:08:40 +02:00
ZXY101
7baf586d3b Merge branch 'main' of https://github.com/ZXY101/z-reader 2024-05-18 16:02:36 +02:00
ZXY101
3e487caad9 Update version 2024-05-18 16:02:19 +02:00
Shaun Tenner
44b8989f2e Merge pull request #17 from ZXY101/gdrive-integration
Add google drive intergration
2024-05-18 15:47:06 +02:00
ZXY101
b4573fd955 Complete Gdrive integration 2024-05-18 15:44:16 +02:00
ZXY101
08eee8f62c Add title to cloud route 2024-03-26 02:17:47 +02:00
ZXY101
37c4aa1572 Update drive styling 2024-03-26 02:16:29 +02:00
ZXY101
3b237e15f5 Update scope 2024-03-26 01:57:17 +02:00
ZXY101
edea5aea08 Update scope 2024-03-26 01:55:39 +02:00
Shaun Tenner
940313310b Merge pull request #13 from ZXY101/google-drive
Add Google drive functionality
2024-03-26 01:23:40 +02:00
ZXY101
6daeb966c5 Add profile uploading + cleanup 2024-03-26 01:21:20 +02:00
ZXY101
8ecc96e92e Update drive styling 2024-03-14 07:53:42 +02:00
ZXY101
cc3448f6ba Add gdrive volume data uploading/downloading 2024-03-14 07:31:13 +02:00
ZXY101
491b9603af Add google drive support 2024-03-13 09:35:44 +02:00
ZXY101
bec5e84067 Update edge buttons 2024-03-13 05:05:51 +02:00
ZXY101
8ef7f6447f Reverse textbox sorting 2024-03-12 10:49:31 +02:00
ZXY101
d57de2ec56 Vix volume sorting 2024-03-12 10:31:56 +02:00
ZXY101
1007b3e4ae Update anki connect quick action 2024-03-12 10:27:17 +02:00
ZXY101
39d4e0f072 Update bounds 2024-03-12 10:17:35 +02:00
ZXY101
5d3f17679b Add id for manga panel 2024-03-10 05:42:01 +02:00
ZXY101
032d56a20e Improve unzip speed 2024-03-10 04:54:29 +02:00
ZXY101
282cad6558 Add line count 2024-03-10 03:32:24 +02:00
ZXY101
5bc486660c Update extatic events 2024-03-10 03:06:46 +02:00
ZXY101
e511a0a39b Add exSTATic support 2024-03-09 12:02:13 +02:00
ZXY101
18f5e3ae46 Fix spacebar navigation 2024-03-01 01:02:09 +02:00
ZXY101
050228765e Fix sort 2024-02-28 00:32:50 +02:00
ZXY101
89acdddccb Fix volume screen sorting 2024-02-28 00:22:13 +02:00
ZXY101
1946b12623 Add single volume deletion 2024-02-28 00:15:36 +02:00
Shaun Tenner
1df387526a Merge pull request #12 from ZXY101/jidoujisho-testing
Temporary Jidoujisho compatibility
2024-02-14 23:55:59 +02:00
ZXY101
491c4efab2 More QOL changes 2024-02-11 08:45:21 +02:00
ZXY101
04dd68b242 Ensure there are always volume defaults 2024-02-11 07:51:11 +02:00
ZXY101
83843ff5dd Various QOL changes 2024-02-11 07:25:41 +02:00
ZXY101
66e8dc6683 Merge branch 'master' of https://github.com/ZXY101/z-reader into jidoujisho-testing 2024-02-08 16:28:54 +02:00
ZXY101
b1c4029345 Fix zoomDefault on count up 2024-02-08 16:28:37 +02:00
ZXY101
49969caff7 Update default image for jidoujisho 2024-02-08 12:50:05 +02:00
ZXY101
34e3dbbe1b Merge branch 'master' of https://github.com/ZXY101/z-reader into jidoujisho-testing 2024-02-08 12:46:27 +02:00
ZXY101
47228f2b96 Fix arrow direction 2024-02-08 12:41:38 +02:00
ZXY101
87be518e02 Dont zoomDefault on timer update 2024-02-08 11:00:59 +02:00
ZXY101
a1a582f786 Minor QOL fixes 2024-02-07 17:18:38 +02:00
ZXY101
50ef5c9f40 Attemp to get jidoujisho support working 2024-02-07 11:19:18 +02:00
ZXY101
28155496fc Make timer controls manual 2024-02-05 18:02:20 +02:00
ZXY101
1c92a749be Refactor stats, add derived catalog stores, improve timer 2024-02-05 15:39:22 +02:00
ZXY101
2e28843a07 Fix ordering of smaller text boxes 2024-02-05 12:43:36 +02:00
ZXY101
159a6d50d4 Ensure images extract into volume folders 2024-02-04 16:15:27 +02:00
ZXY101
28587e15c6 Add upload instructions 2024-02-04 05:53:25 +02:00
ZXY101
5226393eac Allow single zip upload + improve manga extraction 2024-02-04 04:48:15 +02:00
ZXY101
3b2c0bedc3 Remove extra line 2024-01-30 10:30:35 +02:00
ZXY101
6ecf57ccbe Merge https://github.com/ZXY101/z-reader 2024-01-30 10:30:12 +02:00
ZXY101
8757804140 Another attempt to fix import order 2024-01-30 10:29:39 +02:00
ZXY101
dd881da036 Another attempt to fix import order 2024-01-30 10:27:34 +02:00
Shaun Tenner
14d0043bb7 Merge pull request #11 from precondition/patch-1
Lowercase extension of item before comparing with accepted imageTypes
2024-01-30 10:09:47 +02:00
precondition
5bb2a4033a Lowercase extension of item before comparing with valid imageTypes 2024-01-30 01:04:54 +01:00
ZXY101
692071eea0 Fix zip image ordering 2024-01-17 00:17:11 +02:00
ZXY101
eeef343027 Fix the fix 2024-01-13 18:21:39 +02:00
ZXY101
487a591fb6 Another attempt to fix 404 2024-01-13 18:16:49 +02:00
Shaun Tenner
edbd443137 Merge pull request #9 from ZXY101/zxy101/improvements
Catalog search, sort, order + misc cleanup
2024-01-13 17:54:21 +02:00
ZXY101
28201f5d88 Add misc settings + misc cleanup 2024-01-13 17:53:21 +02:00
ZXY101
c817cc8681 Add catalog search, fix sorting 2024-01-08 06:47:30 +02:00
ZXY101
a18f66ca37 uri decode zip namem 2024-01-07 10:05:46 +02:00
ZXY101
801ecf929e Add manga extracting 2024-01-06 17:32:37 +02:00
ZXY101
dffc3cbed0 Update package lock 2024-01-06 15:55:23 +02:00
Shaun Tenner
b07d98a1c1 Another attempt to fix order issues 2023-12-14 11:52:53 +09:00
Shaun Tenner
224f73d053 Attempt to fix order issues 2023-12-14 11:36:11 +09:00
Shaun
a2f59640af Adjust settings buttons 2023-11-13 15:20:00 +09:00
Shaun
58a4b6be16 Adjust double tap zoom 2023-11-13 14:31:32 +09:00
Shaun
5e7ec34300 Fix order for unzipped manga 2023-11-13 14:01:03 +09:00
Shaun
03370f1b9f Prevent edge swipe in reader 2023-11-09 16:29:42 +09:00
Shaun
a4e1e6f54a Add clearer anki connect instructions 2023-11-09 16:05:46 +09:00
Shaun Tenner
942d5d39f7 Update README.md 2023-10-10 03:40:03 +09:00
ZXY101
aa1f4703f1 Add vercel analytics 2023-10-07 09:58:50 +02:00
ZXY101
137ea85d1a Fix credits 2023-10-06 04:12:25 +02:00
ZXY101
40deec881b Merge https://github.com/ZXY101/z-reader 2023-10-06 01:53:31 +02:00
ZXY101
a92828dde0 Add profile importing/exporting 2023-10-06 01:53:18 +02:00
59 changed files with 2855 additions and 631 deletions

28
.dockerignore Normal file
View File

@@ -0,0 +1,28 @@
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.git
.gitignore
README.md
.env
.nyc_output
coverage
.nyc_output
.coverage
coverage.json
*.lcov
.cache
.parcel-cache
.next
.nuxt
dist
build
.DS_Store
*.tgz
*.tar.gz
.vscode
.idea
*.swp
*.swo
*~

View File

@@ -0,0 +1,64 @@
name: Build and Push Docker Image
on:
push:
branches:
- "main"
tags:
- "v*"
workflow_dispatch:
jobs:
docker:
runs-on: ubuntu-latest
if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Ensure full history and all tags are available
fetch-depth: 0
- name: Set up QEMU (multi-arch)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.suda.codes/${{ github.repository }}
tags: |
type=ref,event=tag
- name: Determine latest Git tag
id: latest
shell: bash
run: |
set -euo pipefail
# Fetch tags in case the runner's mirror is stale
git fetch --tags --force --quiet || true
if tag=$(git describe --tags --abbrev=0 2>/dev/null); then
echo "tag=$tag" >> "$GITHUB_OUTPUT"
else
# Fallback when no tags exist
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: gitea.suda.codes
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64
tags: |
${{ steps.meta.outputs.tags }}
gitea.suda.codes/${{ github.repository }}:${{ steps.latest.outputs.tag }}

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image, copy all the files and run the app
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 svelte
COPY --from=builder --chown=svelte:nodejs /app/build ./build
COPY --from=builder --chown=svelte:nodejs /app/package.json ./package.json
COPY --from=builder --chown=svelte:nodejs /app/node_modules ./node_modules
USER svelte
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["npm", "start"]

View File

@@ -10,7 +10,7 @@ https://github.com/ZXY101/mokuro-reader/assets/39561296/45a214a8-3f69-461c-87d7-
- Anki connect integration & image cropping
- Installation and offline support
## Useage:
## Usage:
You can find the reader hosted [here](https://reader.mokuro.app/).
To import your manga, process it with mokuro and then upload your manga along with the generated `.mokuro` file.
@@ -21,7 +21,7 @@ As of the moment base mokuro does not generate the `.mokuro` file, you need to i
pip install git+https://github.com/kha-white/mokuro.git@web-reader
```
Once installed and your manga is processed, import it your manga to the reader.
Once installed and your manga is processed, import your manga into the reader.
## Development:
@@ -33,7 +33,7 @@ cd mokuro-reader
Install dependencies:
```bash
npm run install
npm install
```
Start the dev server:

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://mokuro:password@postgres:5432/mokuro
- HOSTNAME=0.0.0.0
- PORT=3000
depends_on:
- postgres
restart: unless-stopped
networks:
- mokuro-network
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_DB=mokuro
- POSTGRES_USER=mokuro
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
restart: unless-stopped
networks:
- mokuro-network
volumes:
postgres_data:
networks:
mokuro-network:
driver: bridge

1341
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
{
"name": "z-reader",
"version": "0.9.0",
"version": "0.9.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
@@ -14,6 +15,7 @@
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"@types/gapi.client.drive-v3": "^0.0.4",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14",
@@ -36,9 +38,15 @@
},
"type": "module",
"dependencies": {
"@sveltejs/adapter-node": "^1.3.1",
"@types/gapi": "^0.0.47",
"@types/google.accounts": "^0.0.14",
"@types/google.picker": "^0.0.42",
"@vercel/analytics": "^1.1.0",
"@zip.js/zip.js": "^2.7.20",
"dexie": "^4.0.1-alpha.25",
"panzoom": "^9.4.3",
"pg": "^8.16.3",
"svelte-easy-crop": "^2.0.1"
}
}

View File

@@ -3,14 +3,25 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="manifest" crossorigin="use-credentials" href="manifest.json">
<link rel="manifest" crossorigin="use-credentials" href="manifest.json" />
<meta
name="viewport"
content="width=device-width, height=device-height,initial-scale=1, minimum-scale=1, user-scalable=no"
/>
<script src="https://apis.google.com/js/api.js"></script>
<script src="https://accounts.google.com/gsi/client"></script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-white dark:bg-gray-950 dark:text-white">
<div style="display: contents">%sveltekit.body%</div>
<div
id="popupAbout"
class="pageContainer"
style="
display: contents;
background-image: url('https://reader.mokuro.app/_app/immutable/assets/icon.06fcfdd6.webp');
"
>
%sveltekit.body%
</div>
</body>
</html>

View File

@@ -1,6 +1,7 @@
import { showSnackbar } from '$lib/util';
import { writable } from 'svelte/store';
import { blobToBase64 } from '.';
import { blobToBase64, imageResize } from '.';
import { Settings } from '$lib/settings';
type CropperModal = {
open: boolean;
@@ -10,7 +11,7 @@ type CropperModal = {
export const cropperStore = writable<CropperModal | undefined>(undefined);
export function showCropper(image: string, sentence: string) {
export function showCropper(image: string, sentence?: string) {
cropperStore.set({
open: true,
image,
@@ -34,7 +35,7 @@ function getRadianAngle(degreeValue: number) {
export type Pixels = { width: number; height: number; x: number; y: number }
export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, rotation = 0) {
export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, settings: Settings, rotation = 0 ) {
const image = await createImage(imageSrc);
const canvas = new OffscreenCanvas(image.width, image.height);
const ctx = canvas.getContext('2d');
@@ -72,7 +73,8 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Pixels, rotatio
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
);
const blob = await canvas.convertToBlob({ type: 'image/webp' });
await imageResize(canvas, ctx, settings.ankiConnectSettings.widthField, settings.ankiConnectSettings.heightField);
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: settings.ankiConnectSettings.qualityField });
return await blobToBase64(blob)
}

View File

@@ -1,4 +1,4 @@
import { settings } from "$lib/settings";
import { Settings, settings } from "$lib/settings";
import { showSnackbar } from "$lib/util"
import { get } from "svelte/store";
@@ -50,21 +50,45 @@ export async function blobToBase64(blob: Blob) {
});
}
export async function imageToWebp(source: File) {
export async function imageToWebp(source: File, settings: Settings) {
const image = await createImageBitmap(source);
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d");
if (context) {
context.drawImage(image, 0, 0);
const blob = await canvas.convertToBlob({ type: 'image/webp' });
await imageResize(canvas, context, settings.ankiConnectSettings.widthField, settings.ankiConnectSettings.heightField);
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: settings.ankiConnectSettings.qualityField });
image.close();
return await blobToBase64(blob);
}
}
export async function updateLastCard(imageData: string | null | undefined, sentence: string) {
export async function imageResize(canvas: OffscreenCanvas, ctx: OffscreenCanvasRenderingContext2D, maxWidth: number, maxHeight: number): Promise<OffscreenCanvas> {
return new Promise((resolve, reject) => {
const widthRatio = maxWidth <= 0 ? 1 : maxWidth / canvas.width;
const heightRatio = maxHeight <= 0 ? 1 : maxHeight / canvas.height;
const ratio = Math.min(1, Math.min(widthRatio, heightRatio));
if (ratio < 1) {
const newWidth = canvas.width * ratio;
const newHeight = canvas.height * ratio;
createImageBitmap(canvas, { resizeWidth: newWidth, resizeHeight: newHeight, resizeQuality: 'high' })
.then((sprite) => {
canvas.width = newWidth;
canvas.height = newHeight;
ctx.drawImage(sprite, 0, 0);
resolve(canvas);
})
.catch((e) => reject(e));
} else {
resolve(canvas);
}
});
}
export async function updateLastCard(imageData: string | null | undefined, sentence?: string) {
const {
overwriteImage,
enabled,
@@ -88,7 +112,7 @@ export async function updateLastCard(imageData: string | null | undefined, sente
const fields: Record<string, any> = {};
if (grabSentence) {
if (grabSentence && sentence) {
fields[sentenceField] = sentence;
}
@@ -102,7 +126,7 @@ export async function updateLastCard(imageData: string | null | undefined, sente
id,
fields,
picture: {
filename: `_${id}.webp`,
filename: `mokuro_${id}.webp`,
data: imageData.split(';base64,')[1],
fields: [pictureField],
},

View File

@@ -1,4 +1,30 @@
import { db } from '$lib/catalog/db';
import { page } from '$app/stores';
import { db, type Catalog } from '$lib/catalog/db';
import type { Volume } from '$lib/types';
import { liveQuery } from 'dexie';
import { derived, type Readable } from 'svelte/store';
export const catalog = liveQuery(() => db.catalog.toArray());
function sortManga(a: Volume, b: Volume) {
if (a.volumeName < b.volumeName) {
return -1;
}
if (a.volumeName > b.volumeName) {
return 1;
}
return 0;
}
export const manga = derived([page, catalog as unknown as Readable<Catalog[]>], ([$page, $catalog]) => {
if ($page && $catalog) {
return $catalog.find((item) => item.id === $page.params.manga)?.manga.sort(sortManga)
}
});
export const volume = derived(([page, manga]), ([$page, $manga]) => {
if ($page && $manga) {
return $manga.find((item) => item.mokuroData.volume_uuid === $page.params.volume)
}
})

View File

@@ -1,17 +1,78 @@
<script lang="ts">
import { catalog } from '$lib/catalog';
import { Button, Search, Listgroup } from 'flowbite-svelte';
import CatalogItem from './CatalogItem.svelte';
import Loader from './Loader.svelte';
import { GridOutline, SortOutline, ListOutline } from 'flowbite-svelte-icons';
import { miscSettings, updateMiscSetting } from '$lib/settings';
import CatalogListItem from './CatalogListItem.svelte';
$: sortedCatalog = $catalog
?.sort((a, b) => {
if ($miscSettings.gallerySorting === 'ASC') {
return a.manga[0].mokuroData.title.localeCompare(b.manga[0].mokuroData.title);
} else {
return b.manga[0].mokuroData.title.localeCompare(a.manga[0].mokuroData.title);
}
})
.filter((item) => {
return item.manga[0].mokuroData.title.toLowerCase().indexOf(search.toLowerCase()) !== -1;
});
let search = '';
function onLayout() {
if ($miscSettings.galleryLayout === 'list') {
updateMiscSetting('galleryLayout', 'grid');
} else {
updateMiscSetting('galleryLayout', 'list');
}
}
function onOrder() {
if ($miscSettings.gallerySorting === 'ASC') {
updateMiscSetting('gallerySorting', 'DESC');
} else {
updateMiscSetting('gallerySorting', 'ASC');
}
}
</script>
{#if $catalog}
{#if $catalog.length > 0}
<div class="flex flex-col gap-5">
<div class="flex gap-1 py-2">
<Search bind:value={search} />
<Button size="sm" color="alternative" on:click={onLayout}>
{#if $miscSettings.galleryLayout === 'list'}
<GridOutline />
{:else}
<ListOutline />
{/if}
</Button>
<Button size="sm" color="alternative" on:click={onOrder}>
<SortOutline />
</Button>
</div>
{#if search && sortedCatalog.length === 0}
<div class="text-center p-20">
<p>No results found.</p>
</div>
{:else}
<div class="flex sm:flex-row flex-col gap-5 flex-wrap justify-center sm:justify-start">
{#each $catalog as { id } (id)}
{#if $miscSettings.galleryLayout === 'grid'}
{#each sortedCatalog as { id } (id)}
<CatalogItem {id} />
{/each}
{:else}
<Listgroup active class="w-full">
{#each sortedCatalog as { id } (id)}
<CatalogListItem {id} />
{/each}
</Listgroup>
{/if}
</div>
{/if}
</div>
{:else}
<div class="text-center p-20">

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { catalog } from '$lib/catalog';
import { P } from 'flowbite-svelte';
export let id: string;
@@ -19,7 +18,9 @@
class="object-contain sm:w-[250px] sm:h-[350px] bg-black border-gray-900 border"
/>
{/if}
<p class="font-semibold">{manga.mokuroData.title}</p>
<p class="font-semibold sm:w-[250px] line-clamp-1">
{manga.mokuroData.title}
</p>
</div>
</a>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { catalog } from '$lib/catalog';
import { ListgroupItem } from 'flowbite-svelte';
export let id: string;
$: manga = $catalog?.find((item) => item.id === id)?.manga[0];
</script>
{#if manga}
<div>
<ListgroupItem>
<a href={id} class="h-full w-full">
<div class="flex justify-between items-center">
<p class="font-semibold text-white">{manga.mokuroData.title}</p>
<img
src={URL.createObjectURL(Object.values(manga.files)[0])}
alt="img"
class="object-contain w-[50px] h-[70px] bg-black border-gray-900 border"
/>
</div>
</a>
</ListgroupItem>
</div>
{/if}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Navbar, NavBrand } from 'flowbite-svelte';
import { UserSettingsSolid, UploadSolid } from 'flowbite-svelte-icons';
import { afterNavigate } from '$app/navigation';
import { UserSettingsSolid, UploadSolid, CloudArrowUpOutline } from 'flowbite-svelte-icons';
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import Settings from './Settings/Settings.svelte';
import UploadModal from './UploadModal.svelte';
@@ -37,6 +37,7 @@
<div class="flex md:order-2 gap-5">
<UserSettingsSolid class="hover:text-primary-700" on:click={openSettings} />
<UploadSolid class="hover:text-primary-700" on:click={() => (uploadModalOpen = true)} />
<CloudArrowUpOutline class="hover:text-primary-700" on:click={() => goto('/cloud')} />
</div>
</Navbar>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { cropperStore, getCroppedImg, updateLastCard, type Pixels } from '$lib/anki-connect';
import { settings } from '$lib/settings';
import { Button, Modal, Spinner } from 'flowbite-svelte';
import { onMount } from 'svelte';
import Cropper from 'svelte-easy-crop';
@@ -13,6 +14,13 @@
close();
});
beforeNavigate((nav) => {
if (open) {
nav.cancel();
close();
}
});
onMount(() => {
cropperStore.subscribe((value) => {
if (value) {
@@ -27,9 +35,9 @@
}
async function onCrop() {
if ($cropperStore?.image && $cropperStore?.sentence && pixels) {
if ($cropperStore?.image && pixels) {
loading = true;
const imageData = await getCroppedImg($cropperStore.image, pixels);
const imageData = await getCroppedImg($cropperStore.image, pixels, $settings);
updateLastCard(imageData, $cropperStore.sentence);
close();
}
@@ -43,7 +51,7 @@
<Modal title="Crop image" bind:open on:{close}>
{#if $cropperStore?.image && !loading}
<div class=" flex flex-col gap-2">
<div class="relative w-full h-[55svh] sm:h-[70svh]">
<div class="relative w-full h-[55svh] sm:h-[65svh]">
<Cropper
zoomSpeed={0.5}
maxZoom={10}
@@ -51,6 +59,12 @@
on:cropcomplete={onCropComplete}
/>
</div>
{#if $settings.ankiConnectSettings.grabSentence && $cropperStore?.sentence}
<p>
<b>Sentence:</b>
{$cropperStore?.sentence}
</p>
{/if}
<Button on:click={onCrop}>Crop</Button>
<Button on:click={close} outline color="light">Close</Button>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Page } from '$lib/types';
import { onMount } from 'svelte';
import { afterUpdate, onMount, onDestroy } from 'svelte';
import TextBoxes from './TextBoxes.svelte';
import { zoomDefault } from '$lib/panzoom';
@@ -9,7 +9,26 @@
$: url = src ? `url(${URL.createObjectURL(src)})` : '';
let legacy: HTMLElement | null;
onMount(() => {
legacy = document.getElementById('popupAbout');
zoomDefault();
return () => {
setTimeout(() => {
zoomDefault();
}, 10);
};
});
$: {
if (legacy) {
legacy.style.backgroundImage = url;
}
}
afterUpdate(() => {
zoomDefault();
});
</script>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { toggleFullScreen, zoomFitToScreen } from '$lib/panzoom';
import { SpeedDial, SpeedDialButton } from 'flowbite-svelte';
import { settings } from '$lib/settings';
import {
ArrowLeftOutline,
ArrowRightOutline,
CompressOutline,
ImageOutline,
ZoomOutOutline
} from 'flowbite-svelte-icons';
import { imageToWebp, showCropper, updateLastCard } from '$lib/anki-connect';
import { promptConfirmation } from '$lib/util';
export let left: (_e: any, ingoreTimeOut?: boolean) => void;
export let right: (_e: any, ingoreTimeOut?: boolean) => void;
export let src1: File;
export let src2: File | undefined;
let open = false;
function handleZoom() {
zoomFitToScreen();
open = false;
}
function handleLeft(_e: Event) {
left(_e, true);
open = false;
}
function handleRight(_e: Event) {
right(_e, true);
open = false;
}
async function onUpdateCard(src: File | undefined) {
if ($settings.ankiConnectSettings.enabled && src) {
if ($settings.ankiConnectSettings.cropImage) {
showCropper(URL.createObjectURL(src));
} else {
promptConfirmation('Add image to last created anki card?', async () => {
const imageData = await imageToWebp(src, $settings);
updateLastCard(imageData);
});
}
}
open = false;
}
</script>
{#if $settings.quickActions}
<SpeedDial
tooltip="none"
trigger="click"
defaultClass="absolute end-3 bottom-3 z-50"
color="transparent"
bind:open
>
{#if $settings.ankiConnectSettings.enabled}
<SpeedDialButton name={src2 ? '1' : undefined} on:click={() => onUpdateCard(src1)}>
<ImageOutline />
</SpeedDialButton>
{/if}
{#if $settings.ankiConnectSettings.enabled && src2}
<SpeedDialButton name="2" on:click={() => onUpdateCard(src2)}>
<ImageOutline />
</SpeedDialButton>
{/if}
<SpeedDialButton on:click={toggleFullScreen}>
<CompressOutline />
</SpeedDialButton>
<SpeedDialButton on:click={handleZoom}>
<ZoomOutOutline />
</SpeedDialButton>
<SpeedDialButton on:click={handleRight}>
<ArrowRightOutline />
</SpeedDialButton>
<SpeedDialButton on:click={handleLeft}>
<ArrowLeftOutline />
</SpeedDialButton>
</SpeedDial>
{/if}

View File

@@ -8,7 +8,7 @@
zoomFitToScreen
} from '$lib/panzoom';
import { progress, settings, updateProgress, type VolumeSettings } from '$lib/settings';
import { clamp, debounce } from '$lib/util';
import { clamp, debounce, fireExstaticEvent } from '$lib/util';
import { Input, Popover, Range, Spinner } from 'flowbite-svelte';
import MangaPage from './MangaPage.svelte';
import {
@@ -21,6 +21,9 @@
import { page as pageStore } from '$app/stores';
import SettingsButton from './SettingsButton.svelte';
import { getCharCount } from '$lib/util/count-chars';
import QuickActions from './QuickActions.svelte';
import { beforeNavigate } from '$app/navigation';
import { onMount } from 'svelte';
// TODO: Refactor this whole mess
export let volumeSettings: VolumeSettings;
@@ -64,11 +67,13 @@
return;
}
const pageClamped = clamp(newPage, 1, pages?.length);
const { charCount } = getCharCount(pages, pageClamped);
updateProgress(
volume.mokuroData.volume_uuid,
pageClamped,
getCharCount(pages, pageClamped) || 0,
pageClamped === pages.length
charCount,
pageClamped === pages.length || pageClamped === pages.length - 1
);
zoomDefault();
}
@@ -110,14 +115,19 @@
switch (action) {
case 'ArrowLeft':
case 'ArrowUp':
case 'PageUp':
left(event, true);
return;
case 'ArrowUp':
case 'PageUp':
changePage(page - navAmount, true);
return;
case 'ArrowRight':
right(event, true);
return;
case 'ArrowDown':
case 'PageDown':
right(event, true);
case 'Space':
changePage(page + navAmount, true);
return;
case 'Home':
changePage(1, true);
@@ -127,11 +137,6 @@
changePage(pages.length, true);
}
return;
case 'Space':
if (pages && page + 1 <= pages.length) {
changePage(page + 1, true);
}
return;
case 'KeyF':
toggleFullScreen();
return;
@@ -140,8 +145,9 @@
}
}
$: charCount = $settings.charCount ? getCharCount(pages, page) : 0;
$: maxCharCount = getCharCount(pages);
$: charCount = $settings.charCount ? getCharCount(pages, page).charCount : 0;
$: maxCharCount = getCharCount(pages).charCount;
$: totalLineCount = getCharCount(pages).lineCount;
let startX = 0;
let startY = 0;
@@ -190,13 +196,57 @@
const { clientX, clientY } = event;
const { scale } = $panzoomStore.getTransform();
if (scale < 0.5) {
$panzoomStore.zoomTo(clientX, clientY, 3);
if (scale < 1) {
$panzoomStore.zoomTo(clientX, clientY, 1.5);
} else {
zoomFitToScreen();
}
}
}
$: {
if (volume) {
const { charCount, lineCount } = getCharCount(pages, page);
fireExstaticEvent('mokuro-reader:page.change', {
title: volume.mokuroData.title,
volumeName: volume.mokuroData.volume,
currentCharCount: charCount,
currentPage: page,
totalPages: pages.length,
totalCharCount: maxCharCount || 0,
currentLineCount: lineCount,
totalLineCount
});
}
}
onMount(() => {
if ($settings.defaultFullscreen) {
document.documentElement.requestFullscreen();
}
});
beforeNavigate(() => {
if (document.exitFullscreen) {
document.exitFullscreen();
}
if (volume) {
const { charCount, lineCount } = getCharCount(pages, page);
fireExstaticEvent('mokuro-reader:reader.closed', {
title: volume.mokuroData.title,
volumeName: volume.mokuroData.volume,
currentCharCount: charCount,
currentPage: page,
totalPages: pages.length,
totalCharCount: maxCharCount || 0,
currentLineCount: lineCount,
totalLineCount
});
}
});
</script>
<svelte:window
@@ -208,8 +258,14 @@
<svelte:head>
<title>{volume?.mokuroData.volume || 'Volume'}</title>
</svelte:head>
<SettingsButton />
{#if volume && pages}
<QuickActions
{left}
{right}
src1={Object.values(volume?.files)[index]}
src2={!volumeSettings.singlePageView ? Object.values(volume?.files)[index + 1] : undefined}
/>
<SettingsButton />
<Cropper />
<Popover placement="bottom" trigger="click" triggeredBy="#page-num" class="z-20 w-full max-w-xs">
<div class="flex flex-col gap-3">
@@ -261,11 +317,13 @@
<Panzoom>
<button
class="h-full fixed -left-full z-10 w-full hover:bg-slate-400 opacity-[0.01]"
style:margin-left={`${$settings.edgeButtonWidth}px`}
on:mousedown={mouseDown}
on:mouseup={left}
/>
<button
class="h-full fixed -right-full z-10 w-full hover:bg-slate-400 opacity-[0.01]"
style:margin-right={`${$settings.edgeButtonWidth}px`}
on:mousedown={mouseDown}
on:mouseup={right}
/>
@@ -282,13 +340,17 @@
<div
class="flex flex-row"
class:flex-row-reverse={!volumeSettings.rightToLeft}
style:filter={`invert(${$settings.invertColors ? 1 : 0})`}
on:dblclick={onDoubleTap}
role="none"
id="manga-panel"
>
{#key page}
{#if showSecondPage()}
<MangaPage page={pages[index + 1]} src={Object.values(volume?.files)[index + 1]} />
{/if}
<MangaPage page={pages[index]} src={Object.values(volume?.files)[index]} />
{/key}
</div>
</Panzoom>
</div>
@@ -297,11 +359,13 @@
on:mousedown={mouseDown}
on:mouseup={left}
class="left-0 top-0 absolute h-full w-16 hover:bg-slate-400 opacity-[0.01]"
style:width={`${$settings.edgeButtonWidth}px`}
/>
<button
on:mousedown={mouseDown}
on:mouseup={right}
class="right-0 top-0 absolute h-full w-16 hover:bg-slate-400 opacity-[0.01]"
style:width={`${$settings.edgeButtonWidth}px`}
/>
{/if}
{:else}

View File

@@ -7,7 +7,8 @@
export let page: Page;
export let src: File;
$: textBoxes = page.blocks.map((block) => {
$: textBoxes = page.blocks
.map((block) => {
const { img_height, img_width } = page;
const { box, font_size, lines, vertical } = block;
@@ -20,6 +21,7 @@
const width = xmax - xmin;
const height = ymax - ymin;
const area = width * height;
const textBox = {
left: `${xmin}px`,
@@ -28,10 +30,14 @@
height: `${height}px`,
fontSize: $settings.fontSize === 'auto' ? `${font_size}px` : `${$settings.fontSize}pt`,
writingMode: vertical ? 'vertical-rl' : 'horizontal-tb',
lines
lines,
area
};
return textBox;
})
.sort(({ area: a }, { area: b }) => {
return b - a;
});
$: fontWeight = $settings.boldFont ? 'bold' : '400';
@@ -39,6 +45,8 @@
$: border = $settings.textBoxBorders ? '1px solid red' : 'none';
$: contenteditable = $settings.textEditable;
$: triggerMethod = $settings.ankiConnectSettings.triggerMethod || 'both';
async function onUpdateCard(lines: string[]) {
if ($settings.ankiConnectSettings.enabled) {
const sentence = lines.join(' ');
@@ -46,7 +54,7 @@
showCropper(URL.createObjectURL(src), sentence);
} else {
promptConfirmation('Add image to last created anki card?', async () => {
const imageData = await imageToWebp(src);
const imageData = await imageToWebp(src, $settings);
updateLastCard(imageData, sentence);
});
}
@@ -54,16 +62,23 @@
}
function onContextMenu(event: Event, lines: string[]) {
if ($settings.ankiConnectSettings.enabled) {
if (triggerMethod === 'both' || triggerMethod === 'rightClick') {
event.preventDefault();
onUpdateCard(lines);
}
}
function onDoubleTap(event: Event, lines: string[]) {
if (triggerMethod === 'both' || triggerMethod === 'doubleTap') {
event.preventDefault();
onUpdateCard(lines);
}
}
</script>
{#each textBoxes as { fontSize, height, left, lines, top, width, writingMode }, index (`text-box-${index}`)}
{#each textBoxes as { fontSize, height, left, lines, top, width, writingMode }, index (`textBox-${index}`)}
<div
class="text-box"
class="textBox"
style:width
style:height
style:left
@@ -75,7 +90,7 @@
style:writing-mode={writingMode}
role="none"
on:contextmenu={(e) => onContextMenu(e, lines)}
on:dblclick={() => onUpdateCard(lines)}
on:dblclick={(e) => onDoubleTap(e, lines)}
{contenteditable}
>
{#each lines as line}
@@ -85,7 +100,7 @@
{/each}
<style>
.text-box {
.textBox {
color: black;
padding: 0;
position: absolute;
@@ -96,13 +111,13 @@
z-index: 11;
}
.text-box:focus,
.text-box:hover {
.textBox:focus,
.textBox:hover {
background: rgb(255, 255, 255);
border: 1px solid rgba(0, 0, 0, 0);
}
.text-box p {
.textBox p {
display: none;
white-space: nowrap;
letter-spacing: 0.1em;
@@ -113,8 +128,8 @@
z-index: 11;
}
.text-box:focus p,
.text-box:hover p {
.textBox:focus p,
.textBox:hover p {
display: table;
}
</style>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { startCount, volumeStats } from '$lib/settings';
export let count: number | undefined;
export let volumeId: string;
$: active = Boolean(count);
function onClick() {
if (count) {
clearInterval(count);
count = undefined;
} else {
count = startCount(volumeId);
}
}
</script>
<button
class:text-primary-700={!active}
class="mix-blend-difference z-10 fixed opacity-50 right-20 top-5 p-10 m-[-2.5rem]"
on:click={onClick}
>
<p>
{active ? 'Active' : 'Paused'} | Minutes read: {$volumeStats?.timeReadInMinutes}
</p>
</button>

View File

@@ -1,15 +1,8 @@
<script lang="ts">
import { READER_VERSION } from '$lib/consts';
import { showSnackbar } from '$lib/util';
import { toClipboard } from '$lib/util';
import { A, AccordionItem, Badge, Helper, Span } from 'flowbite-svelte';
import { GithubSolid } from 'flowbite-svelte-icons';
function toClipboard() {
navigator.clipboard.writeText(
'pip install git+https://github.com/kha-white/mokuro.git@web-reader'
);
showSnackbar('Copied to clipboard');
}
</script>
<AccordionItem>
@@ -38,7 +31,7 @@
</p>
<div role="none" on:click={toClipboard}>
<code class="text-primary-600 bg-slate-900"
>pip install git+https://github.com/kha-white/mokuro.git@web-reader</code
>pip3 install git+https://github.com/kha-white/mokuro.git@web-reader</code
>
</div>
</div>
@@ -48,7 +41,7 @@
</p>
<Helper
>Created by <A href="https://github.com/ZXY101">ZXY101</A> & <A
class="https://github.com/kha-white/mokuro">kha-white</A
href="https://github.com/kha-white">kha-white</A
></Helper
>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { settings, updateAnkiSetting } from '$lib/settings';
import { AccordionItem, Label, Toggle, Input, Helper } from 'flowbite-svelte';
import { AccordionItem, Label, Toggle, Input, Helper, Select } from 'flowbite-svelte';
$: disabled = !$settings.ankiConnectSettings.enabled;
@@ -11,16 +12,33 @@
let pictureField = $settings.ankiConnectSettings.pictureField;
let sentenceField = $settings.ankiConnectSettings.sentenceField;
let heightField = $settings.ankiConnectSettings.heightField;
let widthField = $settings.ankiConnectSettings.widthField;
let qualityField = $settings.ankiConnectSettings.qualityField;
let triggerMethod = $settings.ankiConnectSettings.triggerMethod;
const triggerOptions = [
{ value: 'rightClick', name: 'Right click (long press on mobile)' },
{ value: 'doubleTap', name: 'Double tap' },
{ value: 'both', name: 'Both' },
{ value: 'neither', name: 'Neither' }
];
</script>
<AccordionItem>
<span slot="header">Anki Connect</span>
<div class="flex flex-col gap-5">
<Helper
>For anki connect integration to work, you must add the reader to your anki connect <code
class="text-primary-500">webCorsOriginList</code
> list</Helper
>For anki connect integration to work, you must add the reader (<code class="text-primary-500"
>{$page.url.origin}</code
>) to your anki connect <b class="text-primary-500">webCorsOriginList</b> list</Helper
>
<Helper>
To trigger the anki connect integration, double click or right click (long press on mobile)
any text box.
</Helper>
<div>
<Toggle bind:checked={enabled} on:change={() => updateAnkiSetting('enabled', enabled)}
>AnkiConnect Integration Enabled</Toggle
@@ -66,5 +84,50 @@
on:change={() => updateAnkiSetting('grabSentence', grabSentence)}>Grab sentence</Toggle
>
</div>
<div>
<Label>
Trigger method:
<Select
on:change={() => updateAnkiSetting('triggerMethod', triggerMethod)}
items={triggerOptions}
bind:value={triggerMethod}
/>
</Label>
</div>
<hr>
<h4>Quality Settings</h4>
<Helper>Allows you to customize the file size stored on your devices</Helper>
<div>
<Label>Max Height (0 = Ignore; 200 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={heightField}
on:change={() => {updateAnkiSetting('heightField', heightField); if (heightField < 0) heightField = 0;}}
min={0}
/>
</div>
<div>
<Label>Max Width (0 = Ignore; 200 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={widthField}
on:change={() => {updateAnkiSetting('widthField', widthField); if (widthField < 0) widthField = 0;}}
min={0}
/>
</div>
<div>
<Label>Quality (Between 0 and 1; 0.5 Recommended):</Label>
<Input
{disabled}
type="number"
bind:value={qualityField}
on:change={() => updateAnkiSetting('qualityField', qualityField)}
min={0}
max={1}
step="0.1"
/>
</div>
</div>
</AccordionItem>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { AccordionItem, Button, Label, Input, Toggle } from 'flowbite-svelte';
import { syncConfig, pullAll, pushAll } from '$lib/settings/sync';
import { get } from 'svelte/store';
let keyLocal: string = get(syncConfig).key || '';
function saveKey() {
syncConfig.update((cfg) => ({ ...cfg, key: keyLocal || null }));
}
function toggleAuto() {
syncConfig.update((cfg) => ({ ...cfg, auto: !cfg.auto }));
}
function toggleNotifications() {
syncConfig.update((cfg) => ({ ...cfg, notifications: !cfg.notifications }));
}
async function onPull() {
try {
await pullAll();
} catch (e) {}
}
async function onPush() {
try {
await pushAll();
} catch (e) {}
}
</script>
<AccordionItem>
<span slot="header">Cloud Sync</span>
<div class="flex flex-col gap-4">
<div>
<Label>Sync Key</Label>
<div class="flex gap-2">
<Input class="flex-1" bind:value={keyLocal} placeholder="Enter a shared key" />
<Button on:click={saveKey}>Save</Button>
</div>
</div>
<div class="flex items-center gap-3">
<Toggle checked={get(syncConfig).auto} on:change={toggleAuto} />
<Label>Auto Sync</Label>
</div>
<div class="flex items-center gap-3">
<Toggle checked={get(syncConfig).notifications} on:change={toggleNotifications} />
<Label>Show Sync Notifications</Label>
</div>
<div class="flex gap-2">
<Button on:click={onPull} outline>Pull from cloud</Button>
<Button on:click={onPush} outline>Push to cloud</Button>
</div>
<p class="text-sm opacity-80">Use the same sync key across devices to share progress and settings. Manga files remain local or in Google Drive.</p>
</div>
</AccordionItem>

View File

@@ -7,7 +7,7 @@
renameProfile
} from '$lib/settings';
import { promptConfirmation, showSnackbar } from '$lib/util';
import { Listgroup, ListgroupItem, Modal, Input, Popover } from 'flowbite-svelte';
import { Listgroup, ListgroupItem, Modal, Input } from 'flowbite-svelte';
import {
CirclePlusSolid,
CopySolid,

View File

@@ -2,6 +2,7 @@
import { changeProfile, currentProfile, profiles } from '$lib/settings';
import { AccordionItem, Button, Select } from 'flowbite-svelte';
import ManageProfilesModal from './ManageProfilesModal.svelte';
import { showSnackbar } from '$lib/util';
export let onClose: () => void;
@@ -16,6 +17,37 @@
onClose();
}
function exportProfiles() {
const link = document.createElement('a');
const json = localStorage.getItem('profiles') || '';
link.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
link.download = 'profiles.json';
link.click();
showSnackbar('Profiles exported');
}
let files: FileList;
function importProfile() {
const [file] = files;
const reader = new FileReader();
reader.onloadend = () => {
const imported = JSON.parse(reader.result?.toString() || '');
profiles.update((prev) => {
return {
...prev,
...imported
};
});
onClose();
showSnackbar('Profiles imported');
};
if (file) {
reader.readAsText(file);
}
}
let manageModalOpen = false;
</script>
@@ -30,5 +62,13 @@
>Manage profiles</Button
>
</div>
<hr class="border-gray-100 opacity-10" />
<div class="flex flex-col gap-2">
<input class="border border-slate-700 rounded-lg" type="file" accept=".json" bind:files />
<Button on:click={importProfile} disabled={!files} size="sm" outline color="blue"
>Import profiles</Button
>
<Button on:click={exportProfiles} size="sm" color="light">Export profiles</Button>
</div>
</div>
</AccordionItem>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { toggleFullScreen } from '$lib/panzoom';
import { isReader } from '$lib/util';
import { Button } from 'flowbite-svelte';
export let hidden = false;
function onClose() {
hidden = true;
history.back();
}
</script>
{#if isReader()}
<div class="flex flex-col gap-2">
<Button color="alternative" on:click={toggleFullScreen}>Toggle fullscreen</Button>
<Button color="alternative" on:click={onClose}>Close reader</Button>
</div>
{/if}

View File

@@ -4,9 +4,14 @@
import ReaderToggles from './ReaderToggles.svelte';
import { settings, updateSetting } from '$lib/settings';
let value = $settings.swipeThreshold;
function onChange() {
updateSetting('swipeThreshold', value);
let swipeThresholdValue = $settings.swipeThreshold;
let edgeButtonWidthValue = $settings.edgeButtonWidth;
function onSwipeChange() {
updateSetting('swipeThreshold', swipeThresholdValue);
}
function onWidthChange() {
updateSetting('edgeButtonWidth', edgeButtonWidthValue);
}
</script>
@@ -18,7 +23,17 @@
<ReaderToggles />
<div>
<Label>Swipe threshold</Label>
<Range on:change={onChange} min={20} max={90} disabled={!$settings.mobile} bind:value />
<Range
on:change={onSwipeChange}
min={20}
max={90}
disabled={!$settings.mobile}
bind:value={swipeThresholdValue}
/>
</div>
<div>
<Label>Edge button width</Label>
<Range on:change={onWidthChange} min={1} max={100} bind:value={edgeButtonWidthValue} />
</div>
</div>
</AccordionItem>

View File

@@ -3,13 +3,18 @@
import { Toggle } from 'flowbite-svelte';
$: toggles = [
{ key: 'defaultFullscreen', text: 'Open reader in fullscreen', value: $settings.defaultFullscreen },
{ key: 'textEditable', text: 'Editable text', value: $settings.textEditable },
{ key: 'textBoxBorders', text: 'Text box borders', value: $settings.textBoxBorders },
{ key: 'displayOCR', text: 'OCR enabled', value: $settings.displayOCR },
{ key: 'boldFont', text: 'Bold font', value: $settings.boldFont },
{ key: 'pageNum', text: 'Show page number', value: $settings.pageNum },
{ key: 'charCount', text: 'Show character count', value: $settings.charCount },
{ key: 'mobile', text: 'Mobile', value: $settings.mobile }
{ key: 'bounds', text: 'Bounds', value: $settings.bounds },
{ key: 'mobile', text: 'Mobile', value: $settings.mobile },
{ key: 'showTimer', text: 'Show timer', value: $settings.showTimer },
{ key: 'quickActions', text: 'Show quick actions', value: $settings.quickActions },
{ key: 'invertColors', text: 'Invert colors of the images', value: $settings.invertColors }
] as { key: SettingsKey; text: string; value: any }[];
</script>

View File

@@ -12,6 +12,9 @@
import VolumeDefaults from './Volume/VolumeDefaults.svelte';
import VolumeSettings from './Volume/VolumeSettings.svelte';
import About from './About.svelte';
import QuickAccess from './QuickAccess.svelte';
import CloudSync from './CloudSync.svelte';
import { beforeNavigate } from '$app/navigation';
let transitionParams = {
x: 320,
@@ -29,6 +32,14 @@
function onClose() {
hidden = true;
}
beforeNavigate((nav) => {
// Only block navigation if settings drawer is open and we're trying to navigate away
if (!hidden && nav.type !== 'popstate') {
nav.cancel();
hidden = true;
}
});
</script>
<Drawer
@@ -47,12 +58,14 @@
</div>
<div class="flex flex-col gap-5">
<Accordion flush>
<QuickAccess bind:hidden />
{#if isReader()}
<VolumeSettings />
{:else}
<VolumeDefaults />
{/if}
<Profiles {onClose} />
<CloudSync />
<ReaderSettings />
<AnkiConnectSettings />
<CatalogSettings />

View File

@@ -1,44 +1,14 @@
<script lang="ts">
import { volumes } from '$lib/settings';
import { AccordionItem, P } from 'flowbite-svelte';
$: completed = $volumes
? Object.values($volumes).reduce((total: number, { completed }) => {
if (completed) {
total++;
}
return total;
}, 0)
: 0;
$: pagesRead = $volumes
? Object.values($volumes).reduce((total: number, { progress }) => {
total += progress;
return total;
}, 0)
: 0;
$: charsRead = $volumes
? Object.values($volumes).reduce((total: number, { chars }) => {
total += chars;
return total;
}, 0)
: 0;
$: minutesRead = $volumes
? Object.values($volumes).reduce((total: number, { timeReadInMinutes }) => {
total += timeReadInMinutes;
return total;
}, 0)
: 0;
import { totalStats } from '$lib/settings';
import { AccordionItem } from 'flowbite-svelte';
</script>
<AccordionItem>
<span slot="header">Stats</span>
<div>
<p>Completed volumes: {completed}</p>
<p>Pages read: {pagesRead}</p>
<p>Characters read: {charsRead}</p>
<p>Minutes read: {minutesRead}</p>
<p>Completed volumes: {$totalStats?.completed || 0}</p>
<p>Pages read: {$totalStats?.pagesRead || 0}</p>
<p>Characters read: {$totalStats?.charsRead || 0}</p>
<p>Minutes read: {$totalStats?.minutesRead || 0}</p>
</div>
</AccordionItem>

View File

@@ -3,13 +3,13 @@
import { AccordionItem, Helper, Toggle } from 'flowbite-svelte';
$: toggles = [
{ key: 'rightToLeft', text: 'Right to left', value: $settings.volumeDefaults.rightToLeft },
{ key: 'rightToLeft', text: 'Right to left', value: $settings.volumeDefaults?.rightToLeft },
{
key: 'singlePageView',
text: 'Single page view',
value: $settings.volumeDefaults.singlePageView
value: $settings.volumeDefaults?.singlePageView
},
{ key: 'hasCover', text: 'First page is cover', value: $settings.volumeDefaults.hasCover }
{ key: 'hasCover', text: 'First page is cover', value: $settings.volumeDefaults?.hasCover }
] as { key: VolumeDefaultsKey; text: string; value: any }[];
</script>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { toggleFullScreen, zoomDefault } from '$lib/panzoom';
import { zoomDefault } from '$lib/panzoom';
import {
updateProgress,
updateVolumeSetting,
@@ -8,7 +8,7 @@
volumeSettings,
type VolumeSettingsKey
} from '$lib/settings';
import { AccordionItem, Button, Helper, Toggle } from 'flowbite-svelte';
import { AccordionItem, Helper, Toggle } from 'flowbite-svelte';
const volumeId = $page.params.volume;
@@ -37,6 +37,5 @@
{#each toggles as { key, text, value }}
<Toggle size="small" checked={value} on:change={() => onChange(key, value)}>{text}</Toggle>
{/each}
<Button color="alternative" on:click={toggleFullScreen}>Toggle fullscreen</Button>
</div>
</AccordionItem>

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { Button, Dropzone, Modal, Spinner } from 'flowbite-svelte';
import { Button, Dropzone, Modal, Spinner, Accordion, AccordionItem } from 'flowbite-svelte';
import FileUpload from './FileUpload.svelte';
import { processFiles } from '$lib/upload';
import { onMount } from 'svelte';
import { scanFiles } from '$lib/upload';
import { formatBytes } from '$lib/util/upload';
import { toClipboard } from '$lib/util';
export let open = false;
@@ -90,6 +91,33 @@
<h2 class="justify-center flex">Loading...</h2>
<div class="text-center"><Spinner /></div>
{:then}
<Accordion flush>
<AccordionItem>
<span slot="header">What to upload?</span>
<div class="flex flex-col gap-5">
<div>
<p>
Firstly, ensure that you process your manga with the <b>0.2.0-beta.6</b> of mokuro, you
can install it by running the following command:
</p>
<div role="none" on:click={toClipboard}>
<code class="text-primary-600 bg-slate-900"
>pip3 install git+https://github.com/kha-white/mokuro.git@web-reader</code
>
</div>
</div>
<p>
This will generate a <code>.mokuro</code> file for each volume processed, upload your
manga along with the <code>.mokuro</code> files.
</p>
<p>
On mobile, uploading via directory is not supported so you will need to zip your manga
first and then upload it via
<code class="text-primary-600 bg-slate-900">choose files</code>.
</p>
</div>
</AccordionItem>
</Accordion>
<Dropzone
id="dropzone"
on:drop={dropHandle}
@@ -141,7 +169,6 @@
</p>
{/if}
</Dropzone>
<p class=" text-sm text-gray-500 dark:text-gray-400 text-center">{storageSpace}</p>
<div class="flex flex-1 flex-col gap-2">
<Button outline on:click={reset} {disabled} color="dark">Reset</Button>

View File

@@ -1,34 +1,72 @@
<script lang="ts">
import { page } from '$app/stores';
import { progress } from '$lib/settings';
import { deleteVolume, progress } from '$lib/settings';
import type { Volume } from '$lib/types';
import { ListgroupItem } from 'flowbite-svelte';
import { CheckCircleSolid } from 'flowbite-svelte-icons';
import type { ListGroupItemType } from 'flowbite-svelte/dist/types';
import { promptConfirmation } from '$lib/util';
import { ListgroupItem, Frame } from 'flowbite-svelte';
import { CheckCircleSolid, TrashBinSolid } from 'flowbite-svelte-icons';
import { goto } from '$app/navigation';
import { db } from '$lib/catalog/db';
export let item: string | ListGroupItemType;
const volume = item as Volume;
export let volume: Volume;
const { volumeName, mokuroData } = volume as Volume;
const { title_uuid, volume_uuid } = mokuroData;
const volName = decodeURI(volumeName);
$: currentPage = $progress?.[volume?.mokuroData.volume_uuid || 0] || 1;
$: progressDisplay = `${currentPage} / ${volume.mokuroData.pages.length}`;
$: isComplete = currentPage === volume.mokuroData.pages.length;
$: currentPage = $progress?.[volume_uuid || 0] || 1;
$: progressDisplay = `${
currentPage === volume.mokuroData.pages.length - 1 ? currentPage + 1 : currentPage
} / ${volume.mokuroData.pages.length}`;
$: isComplete =
currentPage === volume.mokuroData.pages.length ||
currentPage === volume.mokuroData.pages.length - 1;
async function onDeleteClicked(e: Event) {
e.stopPropagation();
promptConfirmation(`Delete ${volName}?`, async () => {
const existingCatalog = await db.catalog.get(title_uuid);
const updated = existingCatalog?.manga.filter(({ mokuroData }) => {
return mokuroData.volume_uuid !== volume_uuid;
});
deleteVolume(volume_uuid);
if (updated && updated.length > 0) {
await db.catalog.update(title_uuid, { manga: updated });
goto(`/${$page.params.manga}`);
} else {
db.catalog.delete(title_uuid);
goto('/');
}
});
}
</script>
<a href={`${$page.params.manga}/${mokuroData.volume_uuid}`} class="h-full w-full">
<ListgroupItem>
{#if $page.params.manga}
<Frame rounded border class="divide-y divide-gray-200 dark:divide-gray-600">
<ListgroupItem
on:click={() => goto(`/${$page.params.manga}/${volume_uuid}`)}
normalClass="py-4"
>
<div
class:text-green-400={isComplete}
class="flex flex-row gap-5 items-center justify-between w-full"
>
<div>
<p class="font-semibold" class:text-white={!isComplete}>{decodeURI(volumeName)}</p>
<p class="font-semibold" class:text-white={!isComplete}>{volName}</p>
<p>{progressDisplay}</p>
</div>
<div class="flex gap-2">
<TrashBinSolid
class="text-red-400 hover:text-red-500 z-10 poin"
on:click={onDeleteClicked}
/>
{#if isComplete}
<CheckCircleSolid />
{/if}
</div>
</div>
</ListgroupItem>
</a>
</Frame>
{/if}

View File

@@ -1 +1 @@
export const READER_VERSION = '0.9.0'
export const READER_VERSION = '0.9.1'

View File

@@ -147,9 +147,9 @@ export function keepInBounds() {
return
}
const { mobile } = get(settings)
const { mobile, bounds } = get(settings)
if (!mobile) {
if (!mobile && !bounds) {
return
}
@@ -161,7 +161,7 @@ export function keepInBounds() {
const width = container.offsetWidth * scale;
const height = container.offsetHeight * scale;
const marginX = innerWidth * 0.01;
const marginX = innerWidth * 0.001;
const marginY = innerHeight * 0.01;
let minX = innerWidth - width - marginX;
@@ -190,7 +190,6 @@ export function keepInBounds() {
if (x < minX) {
transform.x = minX;
}
if (x > maxX) {
transform.x = maxX;

77
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,77 @@
import { Pool } from 'pg';
import { env } from '$env/dynamic/private';
let pool: Pool | undefined;
function getPool(): Pool {
if (!pool) {
const connectionString = env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is not set');
}
pool = new Pool({ connectionString, max: 5 });
}
return pool;
}
export async function ensureSchema(): Promise<void> {
const client = await getPool().connect();
try {
await client.query(
`create table if not exists user_sync (
sync_key text primary key,
profiles jsonb,
current_profile text,
misc_settings jsonb,
volumes jsonb,
updated_at timestamptz default now()
)`
);
} finally {
client.release();
}
}
export type SyncRow = {
sync_key: string;
profiles: any | null;
current_profile: string | null;
misc_settings: any | null;
volumes: any | null;
updated_at: string;
};
export async function getSyncRow(syncKey: string): Promise<SyncRow | null> {
await ensureSchema();
const { rows } = await getPool().query('select * from user_sync where sync_key = $1', [syncKey]);
return rows[0] ?? null;
}
export async function upsertSyncRow(
syncKey: string,
data: Partial<Omit<SyncRow, 'sync_key' | 'updated_at'>>
): Promise<SyncRow> {
await ensureSchema();
const { rows } = await getPool().query(
`insert into user_sync (sync_key, profiles, current_profile, misc_settings, volumes)
values ($1, $2, $3, $4, $5)
on conflict (sync_key) do update set
profiles = coalesce(excluded.profiles, user_sync.profiles),
current_profile = coalesce(excluded.current_profile, user_sync.current_profile),
misc_settings = coalesce(excluded.misc_settings, user_sync.misc_settings),
volumes = coalesce(excluded.volumes, user_sync.volumes),
updated_at = now()
returning *`,
[
syncKey,
data.profiles ?? null,
data.current_profile ?? null,
data.misc_settings ?? null,
data.volumes ?? null
]
);
return rows[0];
}

View File

@@ -1,2 +1,4 @@
export * from './volume-data'
export * from './settings'
export * from './misc'
export * from './sync'

33
src/lib/settings/misc.ts Normal file
View File

@@ -0,0 +1,33 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
export type MiscSettings = {
galleryLayout: 'grid' | 'list';
gallerySorting: 'ASC' | 'DESC';
};
export type MiscSettingsKey = keyof MiscSettings;
const defaultSettings: MiscSettings = {
galleryLayout: 'grid',
gallerySorting: 'ASC',
}
const stored = browser ? window.localStorage.getItem('miscSettings') : undefined;
export const miscSettings = writable<MiscSettings>(stored ? JSON.parse(stored) : defaultSettings);
miscSettings.subscribe((miscSettings) => {
if (browser) {
window.localStorage.setItem('miscSettings', JSON.stringify(miscSettings));
}
});
export function updateMiscSetting(key: MiscSettingsKey, value: any) {
miscSettings.update((miscSettings) => {
return {
...miscSettings,
[key]: value
};
});
}

View File

@@ -1,5 +1,4 @@
import { browser } from '$app/environment';
import { zoomDefault } from '$lib/panzoom';
import { derived, get, writable } from 'svelte/store';
export type FontSize =
@@ -29,9 +28,13 @@ export type AnkiConnectSettings = {
enabled: boolean;
pictureField: string;
sentenceField: string;
heightField: number;
widthField: number;
qualityField: number;
cropImage: boolean;
overwriteImage: boolean;
grabSentence: boolean;
triggerMethod: 'rightClick' | 'doubleTap' | 'both'
}
export type VolumeDefaults = {
@@ -41,17 +44,23 @@ export type VolumeDefaults = {
}
export type Settings = {
defaultFullscreen: boolean;
textEditable: boolean;
textBoxBorders: boolean;
displayOCR: boolean;
boldFont: boolean;
pageNum: boolean;
charCount: boolean;
bounds: boolean;
mobile: boolean;
backgroundColor: string;
swipeThreshold: number;
edgeButtonWidth: number;
showTimer: boolean;
quickActions: boolean;
fontSize: FontSize;
zoomDefault: ZoomModes;
invertColors: boolean;
volumeDefaults: VolumeDefaults;
ankiConnectSettings: AnkiConnectSettings;
};
@@ -63,6 +72,7 @@ export type AnkiSettingsKey = keyof AnkiConnectSettings;
export type VolumeDefaultsKey = keyof VolumeDefaults;
const defaultSettings: Settings = {
defaultFullscreen: false,
displayOCR: true,
textEditable: false,
textBoxBorders: false,
@@ -70,10 +80,15 @@ const defaultSettings: Settings = {
pageNum: true,
charCount: false,
mobile: false,
bounds: false,
backgroundColor: '#030712',
swipeThreshold: 50,
edgeButtonWidth: 40,
showTimer: false,
quickActions: true,
fontSize: 'auto',
zoomDefault: 'zoomFitToScreen',
invertColors: false,
volumeDefaults: {
singlePageView: false,
rightToLeft: true,
@@ -85,7 +100,11 @@ const defaultSettings: Settings = {
grabSentence: false,
overwriteImage: true,
pictureField: 'Picture',
sentenceField: 'Sentence'
sentenceField: 'Sentence',
heightField: 0,
widthField: 0,
qualityField: 1,
triggerMethod: 'both'
}
};

109
src/lib/settings/sync.ts Normal file
View File

@@ -0,0 +1,109 @@
import { browser } from '$app/environment';
import { derived, get, writable } from 'svelte/store';
import { debounce, showSnackbar } from '$lib/util';
import { currentProfile, profiles } from './settings';
import { miscSettings } from './misc';
import { volumes } from './volume-data';
export type SyncConfig = {
key: string | null;
auto: boolean;
notifications: boolean;
};
const stored = browser ? window.localStorage.getItem('syncConfig') : undefined;
const initial: SyncConfig = stored ? JSON.parse(stored) : { key: null, auto: false, notifications: true };
export const syncConfig = writable<SyncConfig>(initial);
syncConfig.subscribe((value) => {
if (browser) {
window.localStorage.setItem('syncConfig', JSON.stringify(value));
}
});
export const isSyncEnabled = derived(syncConfig, ($cfg) => Boolean($cfg.key));
async function callApi(method: 'GET' | 'POST', payload?: any) {
if (!browser) return null;
const url = method === 'GET' ? `/api/sync?key=${encodeURIComponent(get(syncConfig).key || '')}` : '/api/sync';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'POST' ? JSON.stringify(payload) : undefined
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function pullAll() {
const cfg = get(syncConfig);
if (!cfg.key) throw new Error('No sync key configured');
const data = await callApi('GET');
if (!data) return;
if (data.profiles) {
profiles.set(data.profiles);
}
if (data.current_profile) {
currentProfile.set(data.current_profile);
}
if (data.misc_settings) {
miscSettings.set(data.misc_settings);
}
if (data.volumes) {
volumes.set(data.volumes);
}
if (cfg.notifications) {
showSnackbar('Pulled from server');
}
}
export async function pushAll() {
const cfg = get(syncConfig);
if (!cfg.key) throw new Error('No sync key configured');
const payload = {
key: cfg.key,
profiles: get(profiles),
current_profile: get(currentProfile),
misc_settings: get(miscSettings),
volumes: get(volumes)
};
await callApi('POST', payload);
if (cfg.notifications) {
showSnackbar('Pushed to server');
}
}
function setupAutoSync() {
if (!browser) return;
let unsubscribeFns: Array<() => void> = [];
function resubscribe() {
unsubscribeFns.forEach((fn) => fn());
unsubscribeFns = [];
if (!get(syncConfig).auto || !get(syncConfig).key) return;
const schedulePush = () => debounce(() => {
pushAll().catch((error) => {
console.error('Auto-sync failed:', error);
});
}, 300);
unsubscribeFns.push(profiles.subscribe(() => schedulePush()));
unsubscribeFns.push(currentProfile.subscribe(() => schedulePush()));
unsubscribeFns.push(miscSettings.subscribe(() => schedulePush()));
unsubscribeFns.push(volumes.subscribe(() => schedulePush()));
}
resubscribe();
unsubscribeFns.push(syncConfig.subscribe(() => resubscribe()));
}
if (browser) {
setupAutoSync();
}

View File

@@ -1,7 +1,9 @@
import { browser } from '$app/environment';
import { derived, get, writable } from 'svelte/store';
import { settings } from './settings';
import { settings, updateSetting, } from './settings';
import { zoomDefault } from '$lib/panzoom';
import { page } from '$app/stores';
import { manga, volume } from '$lib/catalog';
export type VolumeSettings = {
rightToLeft: boolean;
@@ -21,6 +23,13 @@ type VolumeData = {
settings: VolumeSettings;
}
type TotalStats = {
completed: number;
pagesRead: number;
charsRead: number;
minutesRead: number;
}
type Volumes = Record<string, VolumeData>;
@@ -30,7 +39,17 @@ const initial: Volumes = stored && browser ? JSON.parse(stored) : {};
export const volumes = writable<Volumes>(initial);
export function initializeVolume(volume: string) {
const { hasCover, rightToLeft, singlePageView } = get(settings).volumeDefaults
const volumeDefaults = get(settings).volumeDefaults;
if (!volumeDefaults) {
updateSetting('volumeDefaults', {
singlePageView: false,
rightToLeft: true,
hasCover: false
})
}
const { hasCover, rightToLeft, singlePageView } = volumeDefaults
volumes.update((prev) => {
return {
...prev,
@@ -133,3 +152,50 @@ export function updateVolumeSetting(volume: string, key: VolumeSettingsKey, valu
});
zoomDefault();
}
export const totalStats = derived([volumes, page], ([$volumes, $page]) => {
if ($page && $volumes) {
return Object.values($volumes).reduce<TotalStats>((stats, { chars, completed, timeReadInMinutes, progress }) => {
if (completed) {
stats.completed++;
}
stats.pagesRead += progress;
stats.minutesRead += timeReadInMinutes;
stats.charsRead += chars
return stats;
}, {
charsRead: 0,
completed: 0,
pagesRead: 0,
minutesRead: 0
})
}
})
export const mangaStats = derived([manga, volumes], ([$manga, $volumes]) => {
if ($manga && $volumes) {
return $manga.map((vol) => vol.mokuroData.volume_uuid).reduce(
(stats: any, volumeId) => {
const timeReadInMinutes = $volumes[volumeId]?.timeReadInMinutes || 0;
const chars = $volumes[volumeId]?.chars || 0;
const completed = $volumes[volumeId]?.completed || 0;
stats.timeReadInMinutes = stats.timeReadInMinutes + timeReadInMinutes;
stats.chars = stats.chars + chars;
stats.completed = stats.completed + completed;
return stats;
},
{ timeReadInMinutes: 0, chars: 0, completed: 0 }
);
}
});
export const volumeStats = derived([volume, volumes], ([$volume, $volumes]) => {
if ($volume && $volumes) {
const { chars, completed, timeReadInMinutes, progress } = $volumes[$volume.mokuroData.volume_uuid]
return { chars, completed, timeReadInMinutes, progress }
}
});

View File

@@ -2,27 +2,41 @@ import { db } from '$lib/catalog/db';
import type { Volume } from '$lib/types';
import { showSnackbar } from '$lib/util/snackbar';
import { requestPersistentStorage } from '$lib/util/upload';
import { BlobReader, ZipReader, BlobWriter, getMimeType } from '@zip.js/zip.js';
import { ZipReader, BlobWriter, getMimeType, Uint8ArrayReader } from '@zip.js/zip.js';
export * from './web-import'
const zipTypes = ['zip', 'cbz', 'ZIP', 'CBZ'];
const imageTypes = ['image/jpeg', 'image/png', 'image/webp'];
export async function unzipManga(file: File) {
const zipFileReader = new BlobReader(file);
const zipFileReader = new Uint8ArrayReader(new Uint8Array(await file.arrayBuffer()));
const zipReader = new ZipReader(zipFileReader);
const entries = await zipReader.getEntries();
const unzippedFiles: Record<string, File> = {};
for (const entry of entries) {
const sortedEntries = entries.sort((a, b) => {
return a.filename.localeCompare(b.filename, undefined, {
numeric: true,
sensitivity: 'base'
});
})
for (const entry of sortedEntries) {
const mime = getMimeType(entry.filename);
if (imageTypes.includes(mime)) {
const isMokuroFile = entry.filename.split('.').pop() === 'mokuro'
if (imageTypes.includes(mime) || isMokuroFile) {
const blob = await entry.getData?.(new BlobWriter(mime));
if (blob) {
const file = new File([blob], entry.filename, { type: mime });
const fileName = entry.filename.split('/').pop() || entry.filename;
const file = new File([blob], fileName, { type: mime });
if (!file.webkitRelativePath) {
Object.defineProperty(file, 'webkitRelativePath', {
value: entry.filename
})
}
unzippedFiles[entry.filename] = file;
}
}
@@ -90,10 +104,17 @@ export async function scanFiles(item: FileSystemEntry, files: Promise<File | und
}
}
export async function processFiles(files: File[]) {
export async function processFiles(_files: File[]) {
const volumes: Record<string, Volume> = {};
const mangas: string[] = [];
const files = _files.sort((a, b) => {
return decodeURI(a.name).localeCompare(decodeURI(b.name), undefined, {
numeric: true,
sensitivity: 'base'
});
})
for (const file of files) {
const { ext, filename, path } = getDetails(file);
@@ -148,6 +169,11 @@ export async function processFiles(files: File[]) {
if (ext && zipTypes.includes(ext)) {
const unzippedFiles = await unzipManga(file);
if (files.length === 1) {
processFiles(Object.values(unzippedFiles))
return;
}
volumes[path] = {
...volumes[path],
files: unzippedFiles

32
src/lib/util/cloud.ts Normal file
View File

@@ -0,0 +1,32 @@
type FileInfo = {
accessToken: string;
metadata: any;
fileId?: string;
localStorageId: string;
type: string;
}
const FILES_API_URL = 'https://www.googleapis.com/upload/drive/v3/files';
export async function uploadFile({ accessToken, fileId, localStorageId, metadata, type }: FileInfo) {
const json = localStorage.getItem(localStorageId) || '';
const blob = new Blob([json], { type });
const form = new FormData();
form.append('resource', new Blob([JSON.stringify(metadata)], { type }));
form.append('file', blob);
const res = await fetch(
`${FILES_API_URL}${fileId ? `/${fileId}` : ''}?uploadType=multipart`,
{
method: fileId ? 'PATCH' : 'POST',
headers: new Headers({ Authorization: 'Bearer ' + accessToken }),
body: form
}
);
return await res.json()
}

View File

@@ -12,24 +12,28 @@ import type { Page } from "$lib/types";
export function countChars(line: string) {
const isNotJapaneseRegex = /[^0-9A-Z-------\p{Radical}\p{Unified_Ideograph}]+/gimu
const cleaned = line.replace(isNotJapaneseRegex, '')
return cleaned.length;
return Array.from(cleaned).length;
}
export function getCharCount(pages: Page[], currentPage?: number) {
let charCount = 0;
let lineCount = 0;
if (pages && pages.length > 0) {
const max = currentPage || pages.length
let charCount = 0;
for (let i = 0; i < max; i++) {
const blocks = pages[i].blocks;
blocks.forEach((block) => {
lineCount += block.lines.length;
block.lines.forEach((line) => {
charCount += countChars(line);
});
});
}
}
return charCount;
}
return { charCount, lineCount };
}

View File

@@ -2,3 +2,5 @@ export * from './snackbar';
export * from './upload';
export * from './misc';
export * from './modals';
export * from './zip'
export * from './cloud'

View File

@@ -1,5 +1,7 @@
import { page } from "$app/stores";
import { get } from "svelte/store";
import { showSnackbar } from "./snackbar";
import { browser } from "$app/environment";
export function clamp(num: number, min: number, max: number) {
return Math.min(Math.max(num, min), max);
@@ -23,3 +25,29 @@ export function debounce(func: () => void, timeout = 50) {
timer = undefined;
}
}
export function toClipboard() {
navigator.clipboard.writeText(
'pip3 install git+https://github.com/kha-white/mokuro.git@web-reader'
);
showSnackbar('Copied to clipboard');
}
type ExtaticPayload = {
title: string;
volumeName: string;
currentCharCount: number;
totalCharCount: number;
currentPage: number;
totalPages: number;
currentLineCount: number;
totalLineCount: number;
}
type ExtaticEvent = 'mokuro-reader:page.change' | 'mokuro-reader:reader.closed'
export function fireExstaticEvent(event: ExtaticEvent, payload: ExtaticPayload) {
if (browser) {
document.dispatchEvent(new CustomEvent(event, { detail: payload }))
}
}

33
src/lib/util/zip.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { Volume } from "$lib/types";
import {
BlobReader,
BlobWriter,
TextReader,
ZipWriter,
} from "@zip.js/zip.js";
export async function zipManga(manga: Volume[]) {
const zipWriter = new ZipWriter(new BlobWriter("application/zip"));
const promises = manga.map((volume) => {
const imagePromises = Object.values(volume.files).map((file) => {
return zipWriter.add(`${volume.volumeName}/${file.name}`, new BlobReader(file))
})
return [
zipWriter.add(`${volume.volumeName}.mokuro`, new TextReader(JSON.stringify(volume.mokuroData))),
...imagePromises,
]
})
await Promise.all(promises);
const zipFileBlob = await zipWriter.close();
const link = document.createElement('a');
link.href = URL.createObjectURL(zipFileBlob);
link.download = `${manga[0].mokuroData.title}.zip`;
link.click();
return false
}

View File

@@ -1,9 +1,13 @@
<script lang="ts">
import '../app.postcss';
import { dev } from '$app/environment';
import { inject } from '@vercel/analytics';
import NavBar from '$lib/components/NavBar.svelte';
import Snackbar from '$lib/components/Snackbar.svelte';
import ConfirmationPopup from '$lib/components/ConfirmationPopup.svelte';
import { settings } from '$lib/settings';
inject({ mode: dev ? 'development' : 'production' });
</script>
<div class=" h-full min-h-[100svh] text-white">

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import Catalog from '$lib/components/Catalog.svelte';
import { createProfile, deleteProfile } from '$lib/settings';
</script>
<svelte:head>

View File

@@ -4,39 +4,21 @@
import VolumeItem from '$lib/components/VolumeItem.svelte';
import { Button, Listgroup } from 'flowbite-svelte';
import { db } from '$lib/catalog/db';
import { promptConfirmation } from '$lib/util';
import { promptConfirmation, zipManga } from '$lib/util';
import { page } from '$app/stores';
import type { Volume } from '$lib/types';
import { deleteVolume, volumes } from '$lib/settings';
import { deleteVolume, mangaStats, volumes } from '$lib/settings';
function sortManga(a: Volume, b: Volume) {
if (a.volumeName < b.volumeName) {
return -1;
}
if (a.volumeName > b.volumeName) {
return 1;
}
return 0;
return a.mokuroData.volume.localeCompare(b.mokuroData.volume, undefined, {
numeric: true,
sensitivity: 'base'
});
}
$: manga = $catalog?.find((item) => item.id === $page.params.manga)?.manga.sort(sortManga);
$: stats = manga
?.map((vol) => vol.mokuroData.volume_uuid)
?.reduce(
(stats: any, volumeId) => {
const timeReadInMinutes = $volumes[volumeId]?.timeReadInMinutes || 0;
const chars = $volumes[volumeId]?.chars || 0;
const completed = $volumes[volumeId]?.completed || 0;
stats.timeReadInMinutes = stats.timeReadInMinutes + timeReadInMinutes;
stats.chars = stats.chars + chars;
stats.completed = stats.completed + completed;
return stats;
},
{ timeReadInMinutes: 0, chars: 0, completed: 0 }
);
$: loading = false;
async function confirmDelete() {
const title = manga?.[0].mokuroData.title_uuid;
@@ -52,28 +34,40 @@
function onDelete() {
promptConfirmation('Are you sure you want to delete this manga?', confirmDelete);
}
async function onExtract() {
if (manga) {
loading = true;
loading = await zipManga(manga);
}
}
</script>
<svelte:head>
<title>{manga?.[0].mokuroData.title || 'Manga'}</title>
</svelte:head>
{#if manga}
{#if manga && $mangaStats}
<div class="p-2 flex flex-col gap-5">
<div class="flex flex-row justify-between">
<div class="flex flex-col gap-2">
<h3 class="font-bold">{manga[0].mokuroData.title}</h3>
<div class="flex flex-col gap-0 sm:flex-row sm:gap-5">
<p>Volumes: {stats.completed} / {manga.length}</p>
<p>Characters read: {stats.chars}</p>
<p>Minutes read: {stats.timeReadInMinutes}</p>
<p>Volumes: {$mangaStats.completed} / {manga.length}</p>
<p>Characters read: {$mangaStats.chars}</p>
<p>Minutes read: {$mangaStats.timeReadInMinutes}</p>
</div>
</div>
<div>
<div class="sm:block flex-col flex gap-2">
<Button color="alternative" on:click={onDelete}>Remove manga</Button>
<Button color="light" on:click={onExtract} disabled={loading}>
{loading ? 'Extracting...' : 'Extract manga'}
</Button>
</div>
</div>
<Listgroup items={manga} let:item active class="flex-1 h-full w-full">
<VolumeItem {item} />
<Listgroup active class="flex-1 h-full w-full">
{#each manga as volume (volume.mokuroData.volume_uuid)}
<VolumeItem {volume} />
{/each}
</Listgroup>
</div>
{:else}

View File

@@ -3,5 +3,6 @@
<style>
:global(body.reader) {
overflow: hidden !important;
overscroll-behavior: contain;
}
</style>

View File

@@ -1,24 +1,30 @@
<script lang="ts">
import { page } from '$app/stores';
import Reader from '$lib/components/Reader/Reader.svelte';
import { initializeVolume, startCount, volumeSettings, volumes } from '$lib/settings';
import Timer from '$lib/components/Reader/Timer.svelte';
import { initializeVolume, settings, startCount, volumeSettings, volumes } from '$lib/settings';
import { onMount } from 'svelte';
const volumeId = $page.params.volume;
let count: undefined | number = undefined;
onMount(() => {
if (!$volumes?.[volumeId]) {
initializeVolume(volumeId);
}
const count = startCount(volumeId);
count = startCount(volumeId);
return () => {
clearInterval(count);
count = undefined;
};
});
</script>
{#if $volumeSettings[volumeId]}
{#if $settings.showTimer}
<Timer bind:count {volumeId} />
{/if}
<Reader volumeSettings={$volumeSettings[volumeId]} />
{/if}

View File

@@ -0,0 +1,29 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { getSyncRow, upsertSyncRow } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
const syncKey = url.searchParams.get('key');
if (!syncKey) {
return json({ error: 'Missing key' }, { status: 400 });
}
const row = await getSyncRow(syncKey);
if (!row) {
return json({ profiles: null, current_profile: null, misc_settings: null, volumes: null, updated_at: null });
}
return json(row);
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const { key, profiles, current_profile, misc_settings, volumes } = body ?? {};
if (!key) {
return json({ error: 'Missing key' }, { status: 400 });
}
const row = await upsertSyncRow(key, { profiles, current_profile, misc_settings, volumes });
return json(row);
};

View File

@@ -0,0 +1,419 @@
<script lang="ts">
import { processFiles } from '$lib/upload';
import Loader from '$lib/components/Loader.svelte';
import { formatBytes, showSnackbar, uploadFile } from '$lib/util';
import { Button, P, Progressbar } from 'flowbite-svelte';
import { onMount } from 'svelte';
import { promptConfirmation } from '$lib/util';
import { GoogleSolid } from 'flowbite-svelte-icons';
import { profiles, volumes } from '$lib/settings';
const CLIENT_ID = import.meta.env.VITE_GDRIVE_CLIENT_ID;
const API_KEY = import.meta.env.VITE_GDRIVE_API_KEY;
const DISCOVERY_DOC = 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest';
const SCOPES = 'https://www.googleapis.com/auth/drive.file';
const FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder';
const READER_FOLDER = 'mokuro-reader';
const VOLUME_DATA_FILE = 'volume-data.json';
const PROFILES_FILE = 'profiles.json';
const type = 'application/json';
let tokenClient: any;
let accessToken = '';
let gapiLoaded = false;
let googleLoaded = false;
let readerFolderId = '';
let volumeDataId = '';
let profilesId = '';
let loadingMessage = '';
let errorMessage = '';
let completed = 0;
let totalSize = 0;
$: progress = Math.floor((completed / totalSize) * 100).toString();
function xhrDownloadFileId(fileId: string) {
return new Promise<Blob>((resolve, reject) => {
if (!gapiLoaded || !gapi.auth.getToken()) {
reject(new Error('Not authenticated'));
return;
}
const { access_token } = gapi.auth.getToken();
const xhr = new XMLHttpRequest();
completed = 0;
totalSize = 0;
xhr.open('GET', `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`);
xhr.setRequestHeader('Authorization', `Bearer ${access_token}`);
xhr.responseType = 'blob';
xhr.onprogress = ({ loaded, total }) => {
loadingMessage = '';
completed = loaded;
totalSize = total;
};
xhr.onabort = (event) => {
console.warn(`xhr ${fileId}: download aborted at ${event.loaded} of ${event.total}`);
showSnackbar('Download failed');
reject(new Error('Download aborted'));
};
xhr.onerror = (event) => {
console.error(`xhr ${fileId}: download error at ${event.loaded} of ${event.total}`);
showSnackbar('Download failed');
reject(new Error('Error downloading file'));
};
xhr.onload = () => {
completed = 0;
totalSize = 0;
resolve(xhr.response);
};
xhr.ontimeout = (event) => {
console.warn(`xhr ${fileId}: download timeout after ${event.loaded} of ${event.total}`);
showSnackbar('Download timed out');
reject(new Error('Timout downloading file'));
};
xhr.send();
});
}
async function connectDrive(resp?: any) {
if (resp?.error !== undefined) {
throw resp;
}
accessToken = resp?.access_token;
loadingMessage = 'Connecting to drive';
try {
const { result: readerFolderRes } = await gapi.client.drive.files.list({
q: `mimeType='application/vnd.google-apps.folder' and name='${READER_FOLDER}'`,
fields: 'files(id)'
});
if (readerFolderRes.files?.length === 0) {
const { result: createReaderFolderRes } = await gapi.client.drive.files.create({
resource: { mimeType: FOLDER_MIME_TYPE, name: READER_FOLDER },
fields: 'id'
});
readerFolderId = createReaderFolderRes.id || '';
} else {
const id = readerFolderRes.files?.[0]?.id || '';
readerFolderId = id || '';
}
const { result: volumeDataRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${VOLUME_DATA_FILE}'`,
fields: 'files(id, name)'
});
if (volumeDataRes.files?.length !== 0) {
volumeDataId = volumeDataRes.files?.[0].id || '';
}
const { result: profilesRes } = await gapi.client.drive.files.list({
q: `'${readerFolderId}' in parents and name='${PROFILES_FILE}'`,
fields: 'files(id, name)'
});
if (profilesRes.files?.length !== 0) {
profilesId = profilesRes.files?.[0].id || '';
}
loadingMessage = '';
errorMessage = '';
if (accessToken) {
showSnackbar('Connected to Google Drive');
}
} catch (error) {
console.error('Error connecting to Drive:', error);
errorMessage = 'Failed to connect to Google Drive. Please check your credentials.';
loadingMessage = '';
}
}
function signIn() {
if (!gapiLoaded || !googleLoaded) {
errorMessage = 'Google APIs not loaded yet. Please wait and try again.';
return;
}
if (gapi.client.getToken() === null) {
tokenClient.requestAccessToken({ prompt: 'consent' });
} else {
tokenClient.requestAccessToken({ prompt: '' });
}
}
onMount(() => {
// Check if environment variables are set
if (!CLIENT_ID || !API_KEY) {
errorMessage = 'Google Drive integration not configured. Please set VITE_GDRIVE_CLIENT_ID and VITE_GDRIVE_API_KEY environment variables.';
return;
}
// Load Google APIs
if (typeof gapi !== 'undefined') {
gapiLoaded = true;
gapi.load('client', async () => {
try {
await gapi.client.init({
apiKey: API_KEY,
discoveryDocs: [DISCOVERY_DOC]
});
} catch (error) {
console.error('Error initializing gapi client:', error);
errorMessage = 'Failed to initialize Google Drive client.';
}
});
gapi.load('picker', () => {});
} else {
errorMessage = 'Google APIs not loaded. Please check your internet connection.';
}
if (typeof google !== 'undefined') {
googleLoaded = true;
tokenClient = google.accounts.oauth2.initTokenClient({
client_id: CLIENT_ID,
scope: SCOPES,
callback: connectDrive
});
} else {
errorMessage = 'Google OAuth not loaded. Please check your internet connection.';
}
});
function createPicker() {
if (!googleLoaded || !accessToken) {
showSnackbar('Not connected to Google Drive');
return;
}
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes('application/zip,application/x-zip-compressed')
.setMode(google.picker.DocsViewMode.LIST)
.setIncludeFolders(true)
.setParent(readerFolderId);
const picker = new google.picker.PickerBuilder()
.addView(docsView)
.setOAuthToken(accessToken)
.setAppId(CLIENT_ID)
.setDeveloperKey(API_KEY)
.enableFeature(google.picker.Feature.NAV_HIDDEN)
.setCallback(pickerCallback)
.build();
picker.setVisible(true);
}
async function pickerCallback(data: google.picker.ResponseObject) {
try {
if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
loadingMessage = 'Downloading from drive...';
const docs = data[google.picker.Response.DOCUMENTS];
const blob = await xhrDownloadFileId(docs[0].id);
loadingMessage = 'Adding to catalog...';
const file = new File([blob], docs[0].name);
await processFiles([file]);
loadingMessage = '';
}
} catch (error) {
showSnackbar('Something went wrong');
loadingMessage = '';
console.error(error);
}
}
async function onUploadVolumeData() {
const metadata = {
mimeType: type,
name: VOLUME_DATA_FILE,
parents: [volumeDataId ? null : readerFolderId]
};
loadingMessage = 'Uploading volume data';
const res = await uploadFile({
accessToken,
fileId: volumeDataId,
metadata,
localStorageId: 'volumes',
type
});
volumeDataId = res.id;
loadingMessage = '';
if (volumeDataId) {
showSnackbar('Volume data uploaded');
}
}
async function onUploadProfiles() {
const metadata = {
mimeType: type,
name: PROFILES_FILE,
parents: [profilesId ? null : readerFolderId]
};
loadingMessage = 'Uploading profiles';
const res = await uploadFile({
accessToken,
fileId: profilesId,
metadata,
localStorageId: 'profiles',
type
});
profilesId = res.id;
loadingMessage = '';
if (profilesId) {
showSnackbar('Profiles uploaded');
}
}
async function onDownloadVolumeData() {
loadingMessage = 'Downloading volume data';
const { body } = await gapi.client.drive.files.get({
fileId: volumeDataId,
alt: 'media'
});
const downloaded = JSON.parse(body);
volumes.update((prev) => {
return {
...prev,
...downloaded
};
});
loadingMessage = '';
showSnackbar('Volume data downloaded');
}
async function onDownloadProfiles() {
loadingMessage = 'Downloading profiles';
const { body } = await gapi.client.drive.files.get({
fileId: profilesId,
alt: 'media'
});
const downloaded = JSON.parse(body);
profiles.update((prev) => {
return {
...prev,
...downloaded
};
});
loadingMessage = '';
showSnackbar('Profiles downloaded');
}
</script>
<svelte:head>
<title>Cloud</title>
</svelte:head>
<div class="p-2 h-[90svh]">
{#if errorMessage}
<div class="bg-red-900/20 border border-red-700 rounded-lg p-4 mb-4">
<p class="text-red-400">{errorMessage}</p>
</div>
{/if}
{#if loadingMessage || completed > 0}
<Loader>
{#if completed > 0}
<P>{formatBytes(completed)} / {formatBytes(totalSize)}</P>
<Progressbar {progress} />
{:else}
{loadingMessage}
{/if}
</Loader>
{:else if accessToken}
<div class="flex justify-between items-center gap-6 flex-col">
<h2 class="text-3xl font-semibold text-center pt-2">Google Drive:</h2>
<p class="text-center">
Add your zipped manga files to the <span class="text-primary-700">{READER_FOLDER}</span> folder
in your Google Drive.
</p>
<div class="flex flex-col gap-4 w-full max-w-3xl">
<Button color="blue" on:click={createPicker}>Download manga</Button>
<div class="flex-col gap-2 flex">
<Button
color="dark"
on:click={() => promptConfirmation('Upload volume data?', onUploadVolumeData)}
>
Upload volume data
</Button>
{#if volumeDataId}
<Button
color="alternative"
on:click={() =>
promptConfirmation('Download and overwrite volume data?', onDownloadVolumeData)}
>
Download volume data
</Button>
{/if}
</div>
<div class="flex-col gap-2 flex">
<Button
color="dark"
on:click={() => promptConfirmation('Upload profiles?', onUploadProfiles)}
>
Upload profiles
</Button>
{#if profilesId}
<Button
color="alternative"
on:click={() =>
promptConfirmation('Download and overwrite profiles?', onDownloadProfiles)}
>
Download profiles
</Button>
{/if}
</div>
</div>
</div>
{:else}
<div class="flex justify-center pt-0 sm:pt-32">
<button
class="w-full border rounded-lg border-slate-600 p-10 border-opacity-50 hover:bg-slate-800 max-w-3xl"
on:click={signIn}
disabled={!gapiLoaded || !googleLoaded}
>
<div class="flex sm:flex-row flex-col gap-2 items-center justify-center">
<GoogleSolid size="lg" />
<h2 class="text-lg">Connect to Google Drive</h2>
</div>
</button>
{#if !gapiLoaded || !googleLoaded}
<p class="text-sm text-gray-400 mt-2">Loading Google APIs...</p>
{/if}
</div>
{/if}
</div>

View File

@@ -6,7 +6,7 @@
import { promptConfirmation, showSnackbar } from '$lib/util';
import { P, Progressbar } from 'flowbite-svelte';
import { onMount } from 'svelte';
export const BASE_URL = 'https://www.mokuro.moe/manga';
export const BASE_URL = $page.url.searchParams.get('source') || 'https://mokuro.moe/manga';
const manga = $page.url.searchParams.get('manga');
const volume = $page.url.searchParams.get('volume');
@@ -41,7 +41,8 @@
max = items.length;
for (const item of items) {
if (imageTypes.includes('.' + item.pathname.split('.').at(-1) || '')) {
const itemFileExtension = ('.' + item.pathname.split('.').at(-1)).toLowerCase();
if (imageTypes.includes(itemFileExtension || '')) {
const image = await fetch(url + item.pathname);
const blob = await image.blob();
const file = new File([blob], item.pathname.substring(1));

View File

@@ -53,7 +53,7 @@
],
"start_url": "/",
"background_color": "#030712",
"display": "standalone",
"display": "fullscreen",
"scope": "/",
"theme_color": "#030712",
"description": "Reader for Mokuro processed manga"

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */